mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
Adversarial review surfaced six classes of subdirectory-deployment gaps not
covered by the existing TDD scaffold. Each is fixed where it lives, with
pinning tests added beside the change:
Helpers
- navigationUtils: drop `//` from the navigation safety regex so
`openInNewTab('//evil.com')` can no longer open a cross-origin tab
- pathUtils.stripAppRoot: greedy strip so an upstream `/superset/superset/x`
payload survives one strip + react-router basename re-prepend
- RedirectWarning.isAllowedScheme: explicit `//` guard; the `new URL(...)`
catch branch was silently allowing protocol-relative URLs through
- SupersetClientClass.getUrl: implement the runtime appRoot dedupe the
project memory was already documenting. Flips the contract test from
pinning the doubled shape under a misleading name to asserting single-
prefix emission with segment-boundary + bare-root coverage
Frontend literals and sinks
- loggerMiddleware: `/superset/log/` -> `/log/` (matches the live route
after `Superset.route_base = ""`); updated three test fixtures
- DatasetPanel: raw `window.open(explore_url)` -> `openInNewTab` with null guard
- RedirectWarning: raw `window.location.href = targetUrl` -> `redirect()`
so the helpers' validation applies
Backend literals and sinks
- Slice.explore_json_url: `/superset/explore_json` -> `/explore_json`
- Database.sql_url: `/superset/sql/<id>/` (route no longer exists) ->
`/sqllab/?dbid=<id>` (the live SQL Lab deep-link)
- tasks/async_queries.result_url: same `/superset/` strip
- initialization Home menu: hardcoded `href="/superset/welcome/"` ->
`f"{app_root}/welcome/"` so it works under any application_root
FAB list-view raw HTML
- dashboard_link / slice_link render raw `<a href=...>` strings, which do
not receive SCRIPT_NAME at render time. Migrated both to `url_for`
(`Superset.dashboard` / `ExploreView.root`) so subdir deployments emit
single-prefix hrefs. The model properties themselves keep their
router-relative shape for frontend callers using ensureAppRoot
Tests
- test_subdirectory_url_for.py grew from 7 to 11 cases pinning
Slice.explore_json_url, Database.sql_url, dashboard_link, and slice_link
under SCRIPT_NAME=/superset
- 82 helper Jest tests + 71 touched component tests green; pre-commit clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
339 lines
8.7 KiB
TypeScript
339 lines
8.7 KiB
TypeScript
/**
|
|
* 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 { t } from '@apache-superset/core/translation';
|
|
import { Alert } from '@apache-superset/core/components';
|
|
import { styled } from '@apache-superset/core/theme';
|
|
import { Icons } from '@superset-ui/core/components/Icons';
|
|
import { Loading } from '@superset-ui/core/components';
|
|
import Table, {
|
|
ColumnsType,
|
|
TableSize,
|
|
} from '@superset-ui/core/components/Table';
|
|
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
|
|
import { openInNewTab } from 'src/utils/navigationUtils';
|
|
import { ITableColumn } from './types';
|
|
import MessageContent from './MessageContent';
|
|
|
|
/**
|
|
* Enum defining CSS position options
|
|
*/
|
|
enum EPosition {
|
|
ABSOLUTE = 'absolute',
|
|
RELATIVE = 'relative',
|
|
}
|
|
|
|
/**
|
|
* Interface for StyledHeader
|
|
*/
|
|
interface StyledHeaderProps {
|
|
/**
|
|
* Determine the CSS positioning type
|
|
* Vertical centering of loader, No columns screen, and select table screen
|
|
* gets offset when the header position is relative and needs to be absolute, but table
|
|
* needs this positioned relative to render correctly
|
|
*/
|
|
position: EPosition;
|
|
}
|
|
|
|
const MARGIN_MULTIPLIER = 3;
|
|
|
|
const StyledHeader = styled.div<StyledHeaderProps>`
|
|
${({ theme, position }) => `
|
|
position: ${position};
|
|
display: flex;
|
|
align-items: center;
|
|
margin: ${theme.sizeUnit * (MARGIN_MULTIPLIER + 1)}px
|
|
${theme.sizeUnit * MARGIN_MULTIPLIER}px
|
|
${theme.sizeUnit * MARGIN_MULTIPLIER}px
|
|
${theme.sizeUnit * (MARGIN_MULTIPLIER + 3)}px;
|
|
font-size: ${theme.sizeUnit * 5}px;
|
|
font-weight: ${theme.fontWeightStrong};
|
|
padding-bottom: ${theme.sizeUnit * MARGIN_MULTIPLIER}px;
|
|
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
|
|
.anticon:first-of-type {
|
|
margin-right: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
`}
|
|
`;
|
|
|
|
const StyledTitle = styled.div`
|
|
${({ theme }) => `
|
|
margin-left: ${theme.sizeUnit * (MARGIN_MULTIPLIER + 3)}px;
|
|
margin-bottom: ${theme.sizeUnit * MARGIN_MULTIPLIER}px;
|
|
font-weight: ${theme.fontWeightStrong};
|
|
`}
|
|
`;
|
|
|
|
const LoaderContainer = styled.div`
|
|
${({ theme }) => `
|
|
padding: ${theme.sizeUnit * 8}px
|
|
${theme.sizeUnit * 6}px;
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
`}
|
|
`;
|
|
|
|
const StyledLoader = styled.div`
|
|
${({ theme }) => `
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
|
|
div {
|
|
margin-top: ${theme.sizeUnit * MARGIN_MULTIPLIER}px;
|
|
text-align: center;
|
|
font-weight: ${theme.fontWeightNormal};
|
|
font-size: ${theme.fontSize}px;
|
|
color: ${theme.colorTextSecondary};
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const TableContainerWithBanner = styled.div`
|
|
${({ theme }) => `
|
|
position: relative;
|
|
margin: ${theme.sizeUnit * MARGIN_MULTIPLIER}px;
|
|
margin-left: ${theme.sizeUnit * (MARGIN_MULTIPLIER + 3)}px;
|
|
height: calc(100% - ${theme.sizeUnit * 60}px);
|
|
overflow: auto;
|
|
`}
|
|
`;
|
|
|
|
const TableContainerWithoutBanner = styled.div`
|
|
${({ theme }) => `
|
|
position: relative;
|
|
margin: ${theme.sizeUnit * MARGIN_MULTIPLIER}px;
|
|
margin-left: ${theme.sizeUnit * (MARGIN_MULTIPLIER + 3)}px;
|
|
height: calc(100% - ${theme.sizeUnit * 30}px);
|
|
overflow: auto;
|
|
`}
|
|
`;
|
|
|
|
const TableScrollContainer = styled.div`
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
right: 0;
|
|
`;
|
|
|
|
const StyledAlert = styled(Alert)`
|
|
${({ theme }) => `
|
|
border: 1px solid ${theme.colorInfoText};
|
|
padding: ${theme.sizeUnit * 4}px;
|
|
margin: ${theme.sizeUnit * 6}px ${theme.sizeUnit * 6}px
|
|
${theme.sizeUnit * 8}px;
|
|
.view-dataset-button {
|
|
position: absolute;
|
|
top: ${theme.sizeUnit * 4}px;
|
|
right: ${theme.sizeUnit * 4}px;
|
|
|
|
&:hover {
|
|
color: ${theme.colorPrimary};
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
`}
|
|
`;
|
|
|
|
export const REFRESHING = t('Refreshing columns');
|
|
export const COLUMN_TITLE = t('Table columns');
|
|
|
|
const pageSizeOptions = ['5', '10', '15', '25'];
|
|
const DEFAULT_PAGE_SIZE = 25;
|
|
|
|
// Define the columns for Table instance
|
|
export const tableColumnDefinition: ColumnsType<ITableColumn> = [
|
|
{
|
|
title: 'Column Name',
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
sorter: (a: ITableColumn, b: ITableColumn) => a.name.localeCompare(b.name),
|
|
},
|
|
{
|
|
title: 'Datatype',
|
|
dataIndex: 'type',
|
|
key: 'type',
|
|
width: '100px',
|
|
sorter: (a: ITableColumn, b: ITableColumn) => a.name.localeCompare(b.name),
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Props interface for DatasetPanel
|
|
*/
|
|
export interface IDatasetPanelProps {
|
|
/**
|
|
* Name of the database table
|
|
*/
|
|
tableName?: string | null;
|
|
/**
|
|
* Array of ITableColumn instances with name and type attributes
|
|
*/
|
|
columnList: ITableColumn[];
|
|
/**
|
|
* Boolean indicating if there is an error state
|
|
*/
|
|
hasError: boolean;
|
|
/**
|
|
* Boolean indicating if the component is in a loading state
|
|
*/
|
|
loading: boolean;
|
|
datasets?: DatasetObject[] | undefined;
|
|
}
|
|
|
|
const EXISTING_DATASET_DESCRIPTION = t(
|
|
'This table already has a dataset associated with it. You can only associate one dataset with a table.\n',
|
|
);
|
|
const VIEW_DATASET = t('View Dataset');
|
|
|
|
const renderExistingDatasetAlert = (dataset?: DatasetObject) => (
|
|
<StyledAlert
|
|
closable={false}
|
|
type="info"
|
|
showIcon
|
|
message={t('This table already has a dataset')}
|
|
description={
|
|
<>
|
|
{EXISTING_DATASET_DESCRIPTION}
|
|
<span
|
|
role="button"
|
|
onClick={() => {
|
|
if (dataset?.explore_url) {
|
|
openInNewTab(dataset.explore_url);
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
className="view-dataset-button"
|
|
>
|
|
{VIEW_DATASET}
|
|
</span>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
|
|
const DatasetPanel = ({
|
|
tableName,
|
|
columnList,
|
|
loading,
|
|
hasError,
|
|
datasets,
|
|
}: IDatasetPanelProps) => {
|
|
const hasColumns = Boolean(columnList?.length > 0);
|
|
const datasetNames = datasets?.map(dataset => dataset.table_name);
|
|
const tableWithDataset = datasets?.find(
|
|
dataset => dataset.table_name === tableName,
|
|
);
|
|
|
|
let component;
|
|
let loader;
|
|
if (loading) {
|
|
loader = (
|
|
<LoaderContainer>
|
|
<StyledLoader>
|
|
<Loading position="inline-centered" size="m" />
|
|
<div>{REFRESHING}</div>
|
|
</StyledLoader>
|
|
</LoaderContainer>
|
|
);
|
|
}
|
|
if (!loading) {
|
|
if (!loading && tableName && hasColumns && !hasError) {
|
|
component = (
|
|
<>
|
|
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
|
|
{tableWithDataset ? (
|
|
<TableContainerWithBanner>
|
|
<TableScrollContainer>
|
|
<Table
|
|
loading={loading}
|
|
size={TableSize.Small}
|
|
columns={tableColumnDefinition}
|
|
data={columnList}
|
|
pageSizeOptions={pageSizeOptions}
|
|
defaultPageSize={DEFAULT_PAGE_SIZE}
|
|
/>
|
|
</TableScrollContainer>
|
|
</TableContainerWithBanner>
|
|
) : (
|
|
<TableContainerWithoutBanner>
|
|
<TableScrollContainer>
|
|
<Table
|
|
loading={loading}
|
|
size={TableSize.Small}
|
|
columns={tableColumnDefinition}
|
|
data={columnList}
|
|
pageSizeOptions={pageSizeOptions}
|
|
defaultPageSize={DEFAULT_PAGE_SIZE}
|
|
/>
|
|
</TableScrollContainer>
|
|
</TableContainerWithoutBanner>
|
|
)}
|
|
</>
|
|
);
|
|
} else {
|
|
component = (
|
|
<MessageContent
|
|
hasColumns={hasColumns}
|
|
hasError={hasError}
|
|
tableName={tableName}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{tableName && (
|
|
<>
|
|
{datasetNames?.includes(tableName) &&
|
|
renderExistingDatasetAlert(tableWithDataset)}
|
|
<StyledHeader
|
|
position={
|
|
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
|
|
}
|
|
title={tableName || ''}
|
|
>
|
|
<Icons.InsertRowAboveOutlined iconSize="xl" />
|
|
{tableName}
|
|
</StyledHeader>
|
|
</>
|
|
)}
|
|
{component}
|
|
{loader}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DatasetPanel;
|