airbnb/superset

View on GitHub
superset-frontend/src/utils/urlUtils.ts

Summary

Maintainability
B
6 hrs
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 {
  isDefined,
  JsonObject,
  QueryFormData,
  SupersetClient,
} from '@superset-ui/core';
import rison from 'rison';
import { isEmpty } from 'lodash';
import {
  RESERVED_CHART_URL_PARAMS,
  RESERVED_DASHBOARD_URL_PARAMS,
  URL_PARAMS,
} from '../constants';
import { getActiveFilters } from '../dashboard/util/activeDashboardFilters';
import serializeActiveFilterValues from '../dashboard/util/serializeActiveFilterValues';

export type UrlParamType = 'string' | 'number' | 'boolean' | 'object' | 'rison';
export type UrlParam = (typeof URL_PARAMS)[keyof typeof URL_PARAMS];
export function getUrlParam(
  param: UrlParam & { type: 'string' },
): string | null;
export function getUrlParam(
  param: UrlParam & { type: 'number' },
): number | null;
export function getUrlParam(
  param: UrlParam & { type: 'boolean' },
): boolean | null;
export function getUrlParam(
  param: UrlParam & { type: 'object' },
): object | null;
export function getUrlParam(param: UrlParam & { type: 'rison' }): object | null;
export function getUrlParam(
  param: UrlParam & { type: 'rison | string' },
): string | object | null;
export function getUrlParam({ name, type }: UrlParam): unknown {
  const urlParam = new URLSearchParams(window.location.search).get(name);
  switch (type) {
    case 'number':
      if (!urlParam) {
        return null;
      }
      if (urlParam.toLowerCase() === 'true') {
        return 1;
      }
      if (urlParam.toLowerCase() === 'false') {
        return 0;
      }
      if (!Number.isNaN(Number(urlParam))) {
        return Number(urlParam);
      }
      return null;
    case 'object':
      if (!urlParam) {
        return null;
      }
      return JSON.parse(urlParam);
    case 'boolean':
      if (!urlParam) {
        return null;
      }
      return urlParam.toLowerCase() !== 'false' && urlParam !== '0';
    case 'rison':
      if (!urlParam) {
        return null;
      }
      try {
        return rison.decode(urlParam);
      } catch {
        return urlParam;
      }
    default:
      return urlParam;
  }
}

function getUrlParams(excludedParams: string[]): URLSearchParams {
  const urlParams = new URLSearchParams();
  const currentParams = new URLSearchParams(window.location.search);
  currentParams.forEach((value, key) => {
    if (!excludedParams.includes(key)) urlParams.append(key, value);
  });
  return urlParams;
}

export type UrlParamEntries = [string, string][];

function getUrlParamEntries(urlParams: URLSearchParams): UrlParamEntries {
  const urlEntries: [string, string][] = [];
  urlParams.forEach((value, key) => urlEntries.push([key, value]));
  return urlEntries;
}

function getChartUrlParams(excludedUrlParams?: string[]): UrlParamEntries {
  const excludedParams = excludedUrlParams || RESERVED_CHART_URL_PARAMS;
  const urlParams = getUrlParams(excludedParams);
  const filterBoxFilters = getActiveFilters();
  if (
    !isEmpty(filterBoxFilters) &&
    !excludedParams.includes(URL_PARAMS.preselectFilters.name)
  )
    urlParams.append(
      URL_PARAMS.preselectFilters.name,
      JSON.stringify(serializeActiveFilterValues(getActiveFilters())),
    );
  return getUrlParamEntries(urlParams);
}

function getDashboardUrlParams(): UrlParamEntries {
  const urlParams = getUrlParams(RESERVED_DASHBOARD_URL_PARAMS);
  const filterBoxFilters = getActiveFilters();
  if (!isEmpty(filterBoxFilters))
    urlParams.append(
      URL_PARAMS.preselectFilters.name,
      JSON.stringify(serializeActiveFilterValues(getActiveFilters())),
    );
  return getUrlParamEntries(urlParams);
}

function getPermalink(endpoint: string, jsonPayload: JsonObject) {
  return SupersetClient.post({
    endpoint,
    jsonPayload,
  }).then(result => result.json.url as string);
}

export function getChartPermalink(
  formData: Pick<QueryFormData, 'datasource'>,
  excludedUrlParams?: string[],
) {
  return getPermalink('/api/v1/explore/permalink', {
    formData,
    urlParams: getChartUrlParams(excludedUrlParams),
  });
}

export function getDashboardPermalink({
  dashboardId,
  dataMask,
  activeTabs,
  anchor, // the anchor part of the link which corresponds to the tab/chart id
}: {
  dashboardId: string | number;
  /**
   * Current applied data masks (for native filters).
   */
  dataMask: JsonObject;
  /**
   * Current active tabs in the dashboard.
   */
  activeTabs: string[];
  /**
   * The "anchor" component for the permalink. It will be scrolled into view
   * and highlighted upon page load.
   */
  anchor?: string;
}) {
  // only encode filter state if non-empty
  return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, {
    urlParams: getDashboardUrlParams(),
    dataMask,
    activeTabs,
    anchor,
  });
}

const externalUrlRegex =
  /^([^:/?#]+:)?(?:(\/\/)?([^/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/;

// group 1 matches protocol
// group 2 matches '//'
// group 3 matches hostname
export function isUrlExternal(url: string) {
  const match = url.match(externalUrlRegex) || [];
  return (
    (typeof match[1] === 'string' && match[1].length > 0) ||
    match[2] === '//' ||
    (typeof match[3] === 'string' && match[3].length > 0)
  );
}

export function parseUrl(url: string) {
  const match = url.match(externalUrlRegex) || [];
  // if url is external but start with protocol or '//',
  // it can't be used correctly with <a> element
  // in such case, add '//' prefix
  if (isUrlExternal(url) && !isDefined(match[1]) && !url.startsWith('//')) {
    return `//${url}`;
  }
  return url;
}

export function toQueryString(params: Record<string, any>): string {
  const queryParts: string[] = [];
  Object.keys(params).forEach(key => {
    const value = params[key];
    if (value !== null && value !== undefined) {
      queryParts.push(
        `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
      );
    }
  });
  return queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
}