airbnb/caravel

View on GitHub
superset-frontend/src/views/CRUD/utils.tsx

Summary

Maintainability
F
5 days
Test Coverage
/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import {
  css,
  logging,
  styled,
  SupersetClient,
  SupersetClientResponse,
  SupersetTheme,
  getClientErrorObject,
  t,
} from '@superset-ui/core';
import Chart from 'src/types/Chart';
import { intersection } from 'lodash';
import rison from 'rison';
import { FetchDataConfig, FilterValue } from 'src/components/ListView';
import SupersetText from 'src/utils/textUtils';
import { findPermission } from 'src/utils/findPermission';
import { User } from 'src/types/bootstrapTypes';
import { WelcomeTable } from 'src/features/home/types';
import { Dashboard, Filter, TableTab } from './types';

// Modifies the rison encoding slightly to match the backend's rison encoding/decoding. Applies globally.
// Code pulled from rison.js (https://github.com/Nanonid/rison), rison is licensed under the MIT license.
(() => {
  const risonRef: {
    not_idchar: string;
    not_idstart: string;
    id_ok: RegExp;
    next_id: RegExp;
  } = rison as any;

  const l = [];
  for (let hi = 0; hi < 16; hi += 1) {
    for (let lo = 0; lo < 16; lo += 1) {
      if (hi + lo === 0) continue;
      const c = String.fromCharCode(hi * 16 + lo);
      if (!/\w|[-_./~]/.test(c))
        l.push(`\\u00${hi.toString(16)}${lo.toString(16)}`);
    }
  }

  risonRef.not_idchar = l.join('');
  risonRef.not_idstart = '-0123456789';

  const idrx = `[^${risonRef.not_idstart}${risonRef.not_idchar}][^${risonRef.not_idchar}]*`;

  risonRef.id_ok = new RegExp(`^${idrx}$`);
  risonRef.next_id = new RegExp(idrx, 'g');
})();

export const Actions = styled.div`
  color: ${({ theme }) => theme.colors.grayscale.base};
`;

const createFetchResourceMethod =
  (method: string) =>
  (
    resource: string,
    relation: string,
    handleError: (error: Response) => void,
    user?: { userId: string | number; firstName: string; lastName: string },
  ) =>
  async (filterValue = '', page: number, pageSize: number) => {
    const resourceEndpoint = `/api/v1/${resource}/${method}/${relation}`;
    const queryParams = rison.encode_uri({
      filter: filterValue,
      page,
      page_size: pageSize,
    });
    const { json = {} } = await SupersetClient.get({
      endpoint: `${resourceEndpoint}?q=${queryParams}`,
    });

    let fetchedLoggedUser = false;
    const loggedUser = user
      ? {
          label: `${user.firstName} ${user.lastName}`,
          value: user.userId,
        }
      : undefined;

    const data: { label: string; value: string | number }[] = [];
    json?.result
      ?.filter(({ text }: { text: string }) => text.trim().length > 0)
      .forEach(({ text, value }: { text: string; value: string | number }) => {
        if (
          loggedUser &&
          value === loggedUser.value &&
          text === loggedUser.label
        ) {
          fetchedLoggedUser = true;
        } else {
          data.push({
            label: text,
            value,
          });
        }
      });

    if (loggedUser && (!filterValue || fetchedLoggedUser)) {
      data.unshift(loggedUser);
    }

    return {
      data,
      totalCount: json?.count,
    };
  };

export const PAGE_SIZE = 5;
const getParams = (filters?: Filter[], selectColumns?: string[]) => {
  const params = {
    order_column: 'changed_on_delta_humanized',
    order_direction: 'desc',
    page: 0,
    page_size: PAGE_SIZE,
    filters,
    select_columns: selectColumns,
  };
  if (!filters) delete params.filters;
  if (!selectColumns) delete params.select_columns;
  return rison.encode(params);
};

export const getEditedObjects = (userId: string | number) => {
  const filters = {
    edited: [
      {
        col: 'changed_by',
        opr: 'rel_o_m',
        value: `${userId}`,
      },
    ],
  };
  const batch = [
    SupersetClient.get({
      endpoint: `/api/v1/dashboard/?q=${getParams(filters.edited)}`,
    }),
    SupersetClient.get({
      endpoint: `/api/v1/chart/?q=${getParams(filters.edited)}`,
    }),
  ];
  return Promise.all(batch)
    .then(([editedCharts, editedDashboards]) => {
      const res = {
        editedDash: editedDashboards.json?.result.slice(0, 3),
        editedChart: editedCharts.json?.result.slice(0, 3),
      };
      return res;
    })
    .catch(err => err);
};

export const getUserOwnedObjects = (
  userId: string | number,
  resource: string,
  filters: Filter[] = [
    {
      col: 'owners',
      opr: 'rel_m_m',
      value: `${userId}`,
    },
  ],
  selectColumns?: string[],
) =>
  SupersetClient.get({
    endpoint: `/api/v1/${resource}/?q=${getParams(filters, selectColumns)}`,
  }).then(res => res.json?.result);

export const getFilteredChartsandDashboards = (
  addDangerToast: (arg1: string, arg2: any) => any,
  filters: Filter[],
  dashboardSelectColumns?: string[],
  chartSelectColumns?: string[],
) => {
  const newBatch = [
    SupersetClient.get({
      endpoint: `/api/v1/chart/?q=${getParams(filters, chartSelectColumns)}`,
    }),
    SupersetClient.get({
      endpoint: `/api/v1/dashboard/?q=${getParams(
        filters,
        dashboardSelectColumns,
      )}`,
    }),
  ];
  return Promise.all(newBatch)
    .then(([chartRes, dashboardRes]) => ({
      other: [...chartRes.json.result, ...dashboardRes.json.result],
    }))
    .catch(errMsg => {
      addDangerToast(
        t('There was an error fetching the filtered charts and dashboards:'),
        errMsg,
      );
      return { other: [] };
    });
};

export const getRecentActivityObjs = (
  userId: string | number,
  recent: string,
  addDangerToast: (arg1: string, arg2: any) => any,
  filters: Filter[],
) =>
  SupersetClient.get({ endpoint: recent }).then(recentsRes => {
    const res: any = {};
    return getFilteredChartsandDashboards(addDangerToast, filters).then(
      ({ other }) => {
        res.other = other;
        res.viewed = recentsRes.json.result;
        return res;
      },
    );
  });

export const createFetchRelated = createFetchResourceMethod('related');
export const createFetchDistinct = createFetchResourceMethod('distinct');

export function createErrorHandler(
  handleErrorFunc: (
    errMsg?: string | Record<string, string[] | string>,
  ) => void,
) {
  return async (e: SupersetClientResponse | string) => {
    const parsedError = await getClientErrorObject(e);
    // Taking the first error returned from the API
    // @ts-ignore
    const errorsArray = parsedError?.errors;
    const config = await SupersetText;
    if (
      errorsArray?.length &&
      config?.ERRORS &&
      errorsArray[0].error_type in config.ERRORS
    ) {
      parsedError.message = config.ERRORS[errorsArray[0].error_type];
    }
    logging.error(e);
    handleErrorFunc(parsedError.message || parsedError.error);
  };
}

export function handleChartDelete(
  { id, slice_name: sliceName }: Chart,
  addSuccessToast: (arg0: string) => void,
  addDangerToast: (arg0: string) => void,
  refreshData: (arg0?: FetchDataConfig | null) => void,
  chartFilter?: string,
  userId?: string | number,
) {
  const filters = {
    pageIndex: 0,
    pageSize: PAGE_SIZE,
    sortBy: [
      {
        id: 'changed_on_delta_humanized',
        desc: true,
      },
    ],
    filters: [
      {
        id: 'created_by',
        operator: 'rel_o_m',
        value: `${userId}`,
      },
    ],
  };
  SupersetClient.delete({
    endpoint: `/api/v1/chart/${id}`,
  }).then(
    () => {
      if (chartFilter === 'Mine') refreshData(filters);
      else refreshData();
      addSuccessToast(t('Deleted: %s', sliceName));
    },
    () => {
      addDangerToast(t('There was an issue deleting: %s', sliceName));
    },
  );
}

export function handleDashboardDelete(
  { id, dashboard_title: dashboardTitle }: Dashboard,
  refreshData: (config?: FetchDataConfig | null) => void,
  addSuccessToast: (arg0: string) => void,
  addDangerToast: (arg0: string) => void,
  dashboardFilter?: string,
  userId?: string | number,
) {
  return SupersetClient.delete({
    endpoint: `/api/v1/dashboard/${id}`,
  }).then(
    () => {
      const filters = {
        pageIndex: 0,
        pageSize: PAGE_SIZE,
        sortBy: [
          {
            id: 'changed_on_delta_humanized',
            desc: true,
          },
        ],
        filters: [
          {
            id: 'owners',
            operator: 'rel_m_m',
            value: `${userId}`,
          },
        ],
      };
      if (dashboardFilter === 'Mine') refreshData(filters);
      else refreshData();
      addSuccessToast(t('Deleted: %s', dashboardTitle));
    },
    createErrorHandler(errMsg =>
      addDangerToast(
        t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
      ),
    ),
  );
}

export function shortenSQL(sql: string, maxLines: number) {
  let lines: string[] = sql.split('\n');
  if (lines.length >= maxLines) {
    lines = lines.slice(0, maxLines);
    lines.push('...');
  }
  return lines.join('\n');
}

// loading card count for homepage
export const loadingCardCount = 5;

const breakpoints = [576, 768, 992, 1200];
export const mq = breakpoints.map(bp => `@media (max-width: ${bp}px)`);

export const CardContainer = styled.div<{
  showThumbnails?: boolean | undefined;
}>`
  ${({ showThumbnails, theme }) => `
    overflow: hidden;
    display: grid;
    grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px;
    grid-template-columns: repeat(auto-fit, 300px);
    max-height: ${showThumbnails ? '314' : '148'}px;
    margin-top: ${theme.gridUnit * -6}px;
    padding: ${
      showThumbnails
        ? `${theme.gridUnit * 8 + 3}px ${theme.gridUnit * 9}px`
        : `${theme.gridUnit * 8 + 1}px ${theme.gridUnit * 9}px`
    };
  `}
`;

export const CardStyles = styled.div`
  cursor: pointer;
  a {
    text-decoration: none;
  }
  .ant-card-cover > div {
    /* Height is calculated based on 300px width, to keep the same aspect ratio as the 800*450 thumbnails */
    height: 168px;
  }
`;

export const StyledIcon = (theme: SupersetTheme) => css`
  margin: auto ${theme.gridUnit * 2}px auto 0;
  color: ${theme.colors.grayscale.base};
`;

export /* eslint-disable no-underscore-dangle */
const isNeedsPassword = (payload: any) =>
  typeof payload === 'object' &&
  Array.isArray(payload._schema) &&
  !!payload._schema?.find(
    (e: string) => e === 'Must provide a password for the database',
  );

export /* eslint-disable no-underscore-dangle */
const isNeedsSSHPassword = (payload: any) =>
  typeof payload === 'object' &&
  Array.isArray(payload._schema) &&
  !!payload._schema?.find(
    (e: string) => e === 'Must provide a password for the ssh tunnel',
  );

export /* eslint-disable no-underscore-dangle */
const isNeedsSSHPrivateKey = (payload: any) =>
  typeof payload === 'object' &&
  Array.isArray(payload._schema) &&
  !!payload._schema?.find(
    (e: string) => e === 'Must provide a private key for the ssh tunnel',
  );

export /* eslint-disable no-underscore-dangle */
const isNeedsSSHPrivateKeyPassword = (payload: any) =>
  typeof payload === 'object' &&
  Array.isArray(payload._schema) &&
  !!payload._schema?.find(
    (e: string) =>
      e === 'Must provide a private key password for the ssh tunnel',
  );

export const isAlreadyExists = (payload: any) =>
  typeof payload === 'string' &&
  payload.includes('already exists and `overwrite=true` was not passed');

export const getPasswordsNeeded = (errors: Record<string, any>[]) =>
  errors
    .map(error =>
      Object.entries(error.extra)
        .filter(([, payload]) => isNeedsPassword(payload))
        .map(([fileName]) => fileName),
    )
    .flat();

export const getSSHPasswordsNeeded = (errors: Record<string, any>[]) =>
  errors
    .map(error =>
      Object.entries(error.extra)
        .filter(([, payload]) => isNeedsSSHPassword(payload))
        .map(([fileName]) => fileName),
    )
    .flat();

export const getSSHPrivateKeysNeeded = (errors: Record<string, any>[]) =>
  errors
    .map(error =>
      Object.entries(error.extra)
        .filter(([, payload]) => isNeedsSSHPrivateKey(payload))
        .map(([fileName]) => fileName),
    )
    .flat();

export const getSSHPrivateKeyPasswordsNeeded = (
  errors: Record<string, any>[],
) =>
  errors
    .map(error =>
      Object.entries(error.extra)
        .filter(([, payload]) => isNeedsSSHPrivateKeyPassword(payload))
        .map(([fileName]) => fileName),
    )
    .flat();

export const getAlreadyExists = (errors: Record<string, any>[]) =>
  errors
    .map(error =>
      Object.entries(error.extra)
        .filter(([, payload]) => isAlreadyExists(payload))
        .map(([fileName]) => fileName),
    )
    .flat();

export const hasTerminalValidation = (errors: Record<string, any>[]) =>
  errors.some(error => {
    const noIssuesCodes = Object.entries(error.extra).filter(
      ([key]) => key !== 'issue_codes',
    );

    if (noIssuesCodes.length === 0) return true;

    return !noIssuesCodes.every(
      ([, payload]) =>
        isNeedsPassword(payload) ||
        isAlreadyExists(payload) ||
        isNeedsSSHPassword(payload) ||
        isNeedsSSHPrivateKey(payload) ||
        isNeedsSSHPrivateKeyPassword(payload),
    );
  });

export const checkUploadExtensions = (
  perm: Array<string>,
  cons: Array<string>,
) => {
  if (perm !== undefined) {
    return intersection(perm, cons).length > 0;
  }
  return false;
};

export const uploadUserPerms = (
  roles: Record<string, [string, string][]>,
  csvExt: Array<string>,
  colExt: Array<string>,
  excelExt: Array<string>,
  allowedExt: Array<string>,
) => {
  const canUploadCSV =
    findPermission('can_csv_upload', 'Database', roles) &&
    checkUploadExtensions(csvExt, allowedExt);
  const canUploadColumnar =
    checkUploadExtensions(colExt, allowedExt) &&
    findPermission('can_columnar_upload', 'Database', roles);
  const canUploadExcel =
    checkUploadExtensions(excelExt, allowedExt) &&
    findPermission('can_excel_upload', 'Database', roles);
  return {
    canUploadCSV,
    canUploadColumnar,
    canUploadExcel,
    canUploadData: canUploadCSV || canUploadColumnar || canUploadExcel,
  };
};

export function getFilterValues(
  tab: TableTab,
  welcomeTable: WelcomeTable,
  user?: User,
  otherTabFilters?: Filter[],
): FilterValue[] {
  if (
    tab === TableTab.Created ||
    (welcomeTable === WelcomeTable.SavedQueries && tab === TableTab.Mine)
  ) {
    return [
      {
        id: 'created_by',
        operator: 'rel_o_m',
        value: `${user?.userId}`,
      },
    ];
  }
  if (welcomeTable === WelcomeTable.SavedQueries && tab === TableTab.Favorite) {
    return [
      {
        id: 'id',
        operator: 'saved_query_is_fav',
        value: true,
      },
    ];
  }
  if (tab === TableTab.Mine && user) {
    return [
      {
        id: 'owners',
        operator: 'rel_m_m',
        value: `${user.userId}`,
      },
    ];
  }
  if (
    tab === TableTab.Favorite &&
    [WelcomeTable.Dashboards, WelcomeTable.Charts].includes(welcomeTable)
  ) {
    return [
      {
        id: 'id',
        operator:
          welcomeTable === WelcomeTable.Dashboards
            ? 'dashboard_is_favorite'
            : 'chart_is_favorite',
        value: true,
      },
    ];
  }
  if (tab === TableTab.Other) {
    return (otherTabFilters || []).map(flt => ({
      id: flt.col,
      operator: flt.opr,
      value: flt.value,
    }));
  }
  return [];
}