mirror of
https://github.com/apache/superset.git
synced 2026-07-12 09:45:43 +00:00
Compare commits
13 Commits
fix/chart-
...
alexandrus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2529d7c09 | ||
|
|
5f9a68f956 | ||
|
|
33f60e3b16 | ||
|
|
9ec346bb23 | ||
|
|
538eabd6df | ||
|
|
9a17b400da | ||
|
|
6e1d118773 | ||
|
|
0810116646 | ||
|
|
7c474a05d9 | ||
|
|
b1bd7d8be2 | ||
|
|
ae49a80cbe | ||
|
|
dc49f1145a | ||
|
|
d5333ac47b |
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { action } from 'storybook/actions';
|
||||
import { FolderBreadcrumb } from './FolderBreadcrumb';
|
||||
import type { FolderBreadcrumbProps } from './types';
|
||||
|
||||
export default {
|
||||
title: 'Components/Folders/FolderBreadcrumb',
|
||||
component: FolderBreadcrumb,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Folder navigation breadcrumb. Uses a `>` separator, a folder icon per ' +
|
||||
'segment (open folder for the current location), and semantic emphasis ' +
|
||||
'on the trailing segment.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const samplePath = [
|
||||
{ key: 'root', title: 'All assets' },
|
||||
{ key: 'marketing', title: 'Marketing' },
|
||||
{ key: 'q2', title: 'Q2 Campaigns' },
|
||||
];
|
||||
|
||||
export const InteractiveFolderBreadcrumb = (args: FolderBreadcrumbProps) => (
|
||||
<FolderBreadcrumb {...args} />
|
||||
);
|
||||
|
||||
InteractiveFolderBreadcrumb.args = {
|
||||
items: samplePath,
|
||||
separator: '>',
|
||||
};
|
||||
|
||||
InteractiveFolderBreadcrumb.argTypes = {
|
||||
separator: {
|
||||
description: 'Separator rendered between segments.',
|
||||
control: 'text',
|
||||
},
|
||||
items: {
|
||||
description:
|
||||
'Ordered folder path from root to current; the last item is the current location.',
|
||||
control: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Clickable = () => (
|
||||
<FolderBreadcrumb
|
||||
items={samplePath.map(item => ({
|
||||
...item,
|
||||
onClick: action(`navigate:${item.key}`),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
Clickable.parameters = {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Parent segments become links when given an `onClick`; hovering shows the ' +
|
||||
'primary color and clicking fires the handler.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { FolderBreadcrumb } from './FolderBreadcrumb';
|
||||
import type { FolderBreadcrumbItem } from './types';
|
||||
|
||||
const items: FolderBreadcrumbItem[] = [
|
||||
{ key: 'root', title: 'All assets' },
|
||||
{ key: 'analytics', title: 'Analytics' },
|
||||
{ key: 'sales', title: 'Sales' },
|
||||
];
|
||||
|
||||
test('renders every segment in order', () => {
|
||||
render(<FolderBreadcrumb items={items} />);
|
||||
|
||||
expect(screen.getByText('All assets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Analytics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sales')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the default ">" separator between segments', () => {
|
||||
render(<FolderBreadcrumb items={items} />);
|
||||
|
||||
expect(screen.getAllByText('>')).toHaveLength(items.length - 1);
|
||||
});
|
||||
|
||||
test('renders a custom separator', () => {
|
||||
render(<FolderBreadcrumb items={items} separator="/" />);
|
||||
|
||||
expect(screen.getAllByText('/')).toHaveLength(items.length - 1);
|
||||
expect(screen.queryByText('>')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('marks the trailing segment as current with the open-folder icon', () => {
|
||||
render(<FolderBreadcrumb items={items} />);
|
||||
|
||||
// Ancestors get the closed-folder icon; the current leaf gets the open one.
|
||||
expect(screen.getAllByTestId('folder')).toHaveLength(items.length - 1);
|
||||
expect(screen.getByTestId('folder-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('omits the icon for a segment with hideIcon', () => {
|
||||
render(
|
||||
<FolderBreadcrumb
|
||||
items={[
|
||||
{ key: 'root', title: 'All assets', hideIcon: true },
|
||||
{ key: 'sales', title: 'Sales' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The only icon left is the current leaf's open-folder icon.
|
||||
expect(screen.queryByTestId('folder')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('folder-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('invokes onClick with the segment key', async () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<FolderBreadcrumb
|
||||
items={[
|
||||
{ key: 'root', title: 'All assets', onClick },
|
||||
{ key: 'sales', title: 'Sales' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('All assets'));
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('root');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||
import { FolderIcon, FolderOpenIcon } from './assetIcons';
|
||||
import type { FolderBreadcrumbProps } from './types';
|
||||
|
||||
const StyledBreadcrumb = styled(AntdBreadcrumb)`
|
||||
${({ theme }) => `
|
||||
margin: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
|
||||
font-size: ${theme.fontSize}px;
|
||||
line-height: ${theme.sizeUnit * 6}px;
|
||||
|
||||
.ant-breadcrumb-separator {
|
||||
color: ${theme.colorTextTertiary};
|
||||
margin-inline: ${theme.sizeUnit * 2}px;
|
||||
}
|
||||
|
||||
.ant-breadcrumb-link {
|
||||
color: ${theme.colorTextSecondary};
|
||||
}
|
||||
|
||||
a.ant-breadcrumb-link:hover,
|
||||
.ant-breadcrumb-link a:hover {
|
||||
color: ${theme.colorPrimary};
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Semantic emphasis: the trailing item is the current location. */
|
||||
li:last-of-type .ant-breadcrumb-link {
|
||||
color: ${theme.colorText};
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ItemLabel = styled.span<{ clickable?: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
${({ clickable, theme }) =>
|
||||
clickable &&
|
||||
`
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const DEFAULT_SEPARATOR = '>';
|
||||
|
||||
export function FolderBreadcrumb({
|
||||
items,
|
||||
separator = DEFAULT_SEPARATOR,
|
||||
className,
|
||||
}: FolderBreadcrumbProps) {
|
||||
const antdItems = items.map((item, index) => {
|
||||
const isCurrent = index === items.length - 1;
|
||||
const Icon = isCurrent ? FolderOpenIcon : FolderIcon;
|
||||
const clickable = !isCurrent && Boolean(item.onClick || item.href);
|
||||
return {
|
||||
key: item.key,
|
||||
href: item.href,
|
||||
onClick: item.onClick ? () => item.onClick?.(item.key) : undefined,
|
||||
title: (
|
||||
<ItemLabel clickable={clickable}>
|
||||
{!item.hideIcon && <Icon iconSize="m" aria-hidden />}
|
||||
{item.title}
|
||||
</ItemLabel>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledBreadcrumb
|
||||
className={className}
|
||||
separator={separator}
|
||||
items={antdItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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 { useState } from 'react';
|
||||
import { action } from 'storybook/actions';
|
||||
import { FolderBreadcrumb } from './FolderBreadcrumb';
|
||||
import { FolderList } from './FolderList';
|
||||
import type {
|
||||
Folder,
|
||||
FolderAsset,
|
||||
FolderBreadcrumbItem,
|
||||
FolderListProps,
|
||||
} from './types';
|
||||
|
||||
const sampleFolders: Folder[] = [
|
||||
{
|
||||
key: 'sales',
|
||||
title: 'Sales',
|
||||
assets: [
|
||||
{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
|
||||
{ key: 'd2', name: 'Pipeline Health', type: 'dashboard' },
|
||||
{ key: 'ds1', name: 'opportunities', type: 'dataset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
title: 'Marketing',
|
||||
assets: [
|
||||
{ key: 'd3', name: 'Campaign Performance', type: 'dashboard' },
|
||||
{ key: 'c1', name: 'Spend by Channel', type: 'chart' },
|
||||
{ key: 'ds2', name: 'web_events', type: 'dataset' },
|
||||
{ key: 'ds3', name: 'ad_spend', type: 'dataset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
title: 'Product',
|
||||
assets: [{ key: 'c2', name: 'Daily Active Users', type: 'chart' }],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A nested hierarchy for the drill-down story: top-level folders, each holding
|
||||
* dashboards, each dashboard holding its charts.
|
||||
*/
|
||||
const dashboardsByFolder: {
|
||||
key: string;
|
||||
title: string;
|
||||
dashboards: { key: string; title: string; charts: FolderAsset[] }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'sales',
|
||||
title: 'Sales',
|
||||
dashboards: [
|
||||
{
|
||||
key: 'revenue',
|
||||
title: 'Revenue Overview',
|
||||
charts: [
|
||||
{ key: 'mrr', name: 'MRR by month', type: 'chart' },
|
||||
{ key: 'region', name: 'Revenue by region', type: 'chart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pipeline',
|
||||
title: 'Pipeline Health',
|
||||
charts: [{ key: 'opps', name: 'Open opportunities', type: 'chart' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
title: 'Marketing',
|
||||
dashboards: [
|
||||
{
|
||||
key: 'campaigns',
|
||||
title: 'Campaign Performance',
|
||||
charts: [
|
||||
{ key: 'spend', name: 'Spend by channel', type: 'chart' },
|
||||
{ key: 'conversions', name: 'Conversions', type: 'chart' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'Components/Folders/FolderList',
|
||||
component: FolderList,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'A list of folders. A row whose `type` is listed in `expandableTypes` ' +
|
||||
'is a Collapse panel that expands to reveal its assets (or any custom ' +
|
||||
'body supplied via `renderExpandedContent`). Any other row is a single, ' +
|
||||
'non-collapsible entry that calls `onFolderClick` instead — e.g. to ' +
|
||||
'navigate into its own page. With the default empty `expandableTypes`, ' +
|
||||
'nothing expands.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractiveFolderList = (args: FolderListProps) => (
|
||||
<FolderList {...args} />
|
||||
);
|
||||
|
||||
InteractiveFolderList.args = {
|
||||
folders: sampleFolders,
|
||||
expandableTypes: ['folder'],
|
||||
defaultActiveKeys: ['sales'],
|
||||
accordion: false,
|
||||
showCount: true,
|
||||
onAssetClick: action('asset-click'),
|
||||
onFolderClick: action('folder-click'),
|
||||
};
|
||||
|
||||
InteractiveFolderList.argTypes = {
|
||||
expandableTypes: {
|
||||
description:
|
||||
'Entry types that expand in place. Types left out render as a single ' +
|
||||
'row that fires onFolderClick instead.',
|
||||
control: 'check',
|
||||
options: ['folder', 'dashboard', 'dataset', 'chart'],
|
||||
},
|
||||
accordion: {
|
||||
description: 'Only allow a single folder to be open at a time.',
|
||||
control: 'boolean',
|
||||
},
|
||||
showCount: {
|
||||
description: 'Show the asset-count badge next to each folder title.',
|
||||
control: 'boolean',
|
||||
},
|
||||
defaultActiveKeys: {
|
||||
description: 'Keys of folders expanded on first render.',
|
||||
control: false,
|
||||
},
|
||||
folders: {
|
||||
description: 'Folders to render, each with its nested assets.',
|
||||
control: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NavigableFolders = () => (
|
||||
<div>
|
||||
<FolderBreadcrumb
|
||||
items={[
|
||||
{ key: 'root', title: 'All assets', onClick: action('navigate:root') },
|
||||
{ key: 'analytics', title: 'Analytics' },
|
||||
]}
|
||||
/>
|
||||
<FolderList
|
||||
folders={sampleFolders}
|
||||
onFolderClick={action('navigate:folder')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
NavigableFolders.parameters = {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'With an empty `expandableTypes`, folders do not expand; clicking a row ' +
|
||||
'fires `onFolderClick` to drill into it, tracked by the breadcrumb.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomExpandedContent = () => (
|
||||
<FolderList
|
||||
folders={sampleFolders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
renderExpandedContent={folder => (
|
||||
<div style={{ padding: 12 }}>
|
||||
Custom body for <strong>{String(folder.title)}</strong> —{' '}
|
||||
{folder.assets.length} assets
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
CustomExpandedContent.parameters = {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'`renderExpandedContent` replaces the default asset list with arbitrary ' +
|
||||
'content, e.g. a preview for an expandable dashboard entry.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DrillIntoExpandableDashboards = () => {
|
||||
const [openFolder, setOpenFolder] = useState<string | null>(null);
|
||||
const folder = dashboardsByFolder.find(entry => entry.key === openFolder);
|
||||
|
||||
const breadcrumbItems: FolderBreadcrumbItem[] = [
|
||||
{ key: 'root', title: 'All assets', onClick: () => setOpenFolder(null) },
|
||||
...(folder ? [{ key: folder.key, title: folder.title }] : []),
|
||||
];
|
||||
|
||||
// At the root we list folders, which navigate on click and extend the
|
||||
// breadcrumb. Inside a folder we list its dashboards — the only type in
|
||||
// `expandableTypes` — so each expands in place to reveal its charts.
|
||||
const folders: Folder[] = folder
|
||||
? folder.dashboards.map(
|
||||
(dashboard): Folder => ({
|
||||
key: dashboard.key,
|
||||
title: dashboard.title,
|
||||
type: 'dashboard',
|
||||
assets: dashboard.charts,
|
||||
}),
|
||||
)
|
||||
: dashboardsByFolder.map(
|
||||
(entry): Folder => ({
|
||||
key: entry.key,
|
||||
title: entry.title,
|
||||
type: 'folder',
|
||||
assets: entry.dashboards.map(
|
||||
(dashboard): FolderAsset => ({
|
||||
key: dashboard.key,
|
||||
name: dashboard.title,
|
||||
type: 'dashboard',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FolderBreadcrumb items={breadcrumbItems} />
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['dashboard']}
|
||||
onFolderClick={({ key }) => setOpenFolder(key)}
|
||||
onAssetClick={action('open-chart')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DrillIntoExpandableDashboards.parameters = {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Only the `dashboard` type is expandable. At the root, folders are not ' +
|
||||
'expandable: clicking one drills in and extends the breadcrumb, whose ' +
|
||||
'segments are clickable to navigate back. Inside a folder, each ' +
|
||||
'dashboard expands in place to reveal its charts.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBreadcrumb = () => (
|
||||
<div>
|
||||
<FolderBreadcrumb
|
||||
items={[
|
||||
{ key: 'root', title: 'All assets', onClick: action('navigate:root') },
|
||||
{ key: 'analytics', title: 'Analytics' },
|
||||
]}
|
||||
/>
|
||||
<FolderList
|
||||
folders={sampleFolders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales', 'marketing']}
|
||||
onAssetClick={action('asset-click')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
WithBreadcrumb.parameters = {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Breadcrumb and folder list composed together, as they appear on an ' +
|
||||
'analytics listing page.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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 { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { FolderList } from './FolderList';
|
||||
import type { Folder } from './types';
|
||||
|
||||
const folders: Folder[] = [
|
||||
{
|
||||
key: 'sales',
|
||||
title: 'Sales',
|
||||
assets: [
|
||||
{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
|
||||
{ key: 'ds1', name: 'opportunities', type: 'dataset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
title: 'Marketing',
|
||||
assets: [{ key: 'c1', name: 'Spend by Channel', type: 'chart' }],
|
||||
},
|
||||
];
|
||||
|
||||
test('renders a header with the title and asset count for each folder', () => {
|
||||
render(<FolderList folders={folders} expandableTypes={['folder']} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Sales/ })).toHaveTextContent('2');
|
||||
expect(screen.getByRole('button', { name: /Marketing/ })).toHaveTextContent(
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
test('omits the asset count when showCount is false', () => {
|
||||
render(
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['folder']}
|
||||
showCount={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Sales/ })).not.toHaveTextContent(
|
||||
'2',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Marketing/ }),
|
||||
).not.toHaveTextContent('1');
|
||||
});
|
||||
|
||||
test('does not expand folders whose type is not in expandableTypes', () => {
|
||||
render(<FolderList folders={folders} />);
|
||||
|
||||
// Headers still render, but there is no toggle and the assets stay hidden.
|
||||
expect(screen.getByText('Sales')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Sales/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps folder contents collapsed by default', () => {
|
||||
render(<FolderList folders={folders} expandableTypes={['folder']} />);
|
||||
|
||||
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('expands the folders named in defaultActiveKeys', () => {
|
||||
render(
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('opportunities')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reveals folder assets when its header is clicked', async () => {
|
||||
render(<FolderList folders={folders} expandableTypes={['folder']} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Marketing/ }));
|
||||
|
||||
expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onAssetClick with the clicked asset', async () => {
|
||||
const onAssetClick = jest.fn();
|
||||
render(
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
onAssetClick={onAssetClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Revenue Overview'));
|
||||
|
||||
expect(onAssetClick).toHaveBeenCalledWith(folders[0].assets[0]);
|
||||
});
|
||||
|
||||
test('accordion mode hides the open folder when another is opened', async () => {
|
||||
render(
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
accordion
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
|
||||
|
||||
// In accordion mode antd renders the headers as tabs rather than buttons.
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Marketing/ }));
|
||||
|
||||
expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
|
||||
// antd keeps collapsed content mounted but flags it hidden.
|
||||
expect(
|
||||
screen.getByText('Revenue Overview').closest('.ant-collapse-content'),
|
||||
).toHaveClass('ant-collapse-content-hidden');
|
||||
});
|
||||
|
||||
test('calls onFolderClick when a non-expandable row is clicked', async () => {
|
||||
const onFolderClick = jest.fn();
|
||||
render(<FolderList folders={folders} onFolderClick={onFolderClick} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Sales/ }));
|
||||
|
||||
expect(onFolderClick).toHaveBeenCalledWith(folders[0]);
|
||||
// Clicking navigates rather than expanding, so the assets stay hidden.
|
||||
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders custom expanded content instead of the asset list', () => {
|
||||
render(
|
||||
<FolderList
|
||||
folders={folders}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
renderExpandedContent={folder => <div>preview of {folder.key}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('preview of sales')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('expands only the types listed in expandableTypes', async () => {
|
||||
const mixed: Folder[] = [
|
||||
{
|
||||
key: 'sales',
|
||||
title: 'Sales',
|
||||
type: 'folder',
|
||||
assets: [{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' }],
|
||||
},
|
||||
{
|
||||
key: 'kpis',
|
||||
title: 'KPIs',
|
||||
type: 'dashboard',
|
||||
assets: [{ key: 'c1', name: 'Active Users', type: 'chart' }],
|
||||
},
|
||||
];
|
||||
const onFolderClick = jest.fn();
|
||||
render(
|
||||
<FolderList
|
||||
folders={mixed}
|
||||
expandableTypes={['folder']}
|
||||
defaultActiveKeys={['sales']}
|
||||
onFolderClick={onFolderClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The folder expands in place; the dashboard row navigates on click.
|
||||
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Active Users')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /KPIs/ }));
|
||||
|
||||
expect(onFolderClick).toHaveBeenCalledWith(mixed[1]);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
import { Collapse, type CollapseProps } from '../Collapse';
|
||||
import { FolderIcon, assetTypeIcons } from './assetIcons';
|
||||
import type { Folder, FolderItemType, FolderListProps } from './types';
|
||||
|
||||
type FolderCollapseItem = NonNullable<CollapseProps['items']>[number];
|
||||
|
||||
// Keep the header text at its content width and drop antd's auto end-margin so
|
||||
// the end-positioned caret sits immediately after the folder label rather than
|
||||
// at the far right of the row. The doubled root (&&) raises specificity above
|
||||
// antd's `flex: auto` / `margin-inline-end: auto` rules on the header text.
|
||||
const StyledFolderCollapse = styled(Collapse)`
|
||||
${({ theme }) => `
|
||||
&& .ant-collapse-item > .ant-collapse-header > .ant-collapse-header-text {
|
||||
flex: none;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
margin-inline-start: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
/* Non-expandable rows have no caret, so let the label fill the row. */
|
||||
&& .folder-row--static > .ant-collapse-header > .ant-collapse-header-text {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
/* A non-expandable row that navigates on click reads as one target. */
|
||||
&& .folder-row--clickable > .ant-collapse-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&& .folder-row--clickable > .ant-collapse-header:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const FolderLabel = styled.span`
|
||||
${({ theme }) => `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
`}
|
||||
`;
|
||||
|
||||
const AssetCount = styled.span`
|
||||
${({ theme }) => `
|
||||
color: ${theme.colorTextTertiary};
|
||||
font-weight: normal;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const AssetList = styled.ul`
|
||||
${({ theme }) => `
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li + li {
|
||||
border-top: 1px solid ${theme.colorBorderSecondary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const AssetRow = styled.button`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
width: 100%;
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit}px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: ${theme.colorText};
|
||||
font-size: ${theme.fontSize}px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
// Strips the native button chrome so a navigable folder row matches the look
|
||||
// of an expandable header while staying a real, focusable button.
|
||||
const FolderRowButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const iconFor = (type: FolderItemType) =>
|
||||
type === 'folder' ? FolderIcon : assetTypeIcons[type];
|
||||
|
||||
export function FolderList({
|
||||
folders,
|
||||
expandableTypes = [],
|
||||
defaultActiveKeys,
|
||||
accordion = false,
|
||||
showCount = true,
|
||||
onAssetClick,
|
||||
onFolderClick,
|
||||
renderExpandedContent,
|
||||
className,
|
||||
}: FolderListProps) {
|
||||
const isExpandable = (folder: Folder) =>
|
||||
expandableTypes.includes(folder.type ?? 'folder');
|
||||
|
||||
const defaultAssetList = (folder: Folder) => (
|
||||
<AssetList>
|
||||
{folder.assets.map(asset => {
|
||||
const AssetIcon = assetTypeIcons[asset.type];
|
||||
return (
|
||||
<li key={asset.key}>
|
||||
<AssetRow type="button" onClick={() => onAssetClick?.(asset)}>
|
||||
<AssetIcon iconSize="m" aria-hidden />
|
||||
{asset.name}
|
||||
</AssetRow>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</AssetList>
|
||||
);
|
||||
|
||||
const items = folders.map((folder): FolderCollapseItem => {
|
||||
const Icon = iconFor(folder.type ?? 'folder');
|
||||
const label = (
|
||||
<FolderLabel>
|
||||
<Icon iconSize="m" aria-hidden />
|
||||
{folder.title}
|
||||
{showCount && <AssetCount>{folder.assets.length}</AssetCount>}
|
||||
</FolderLabel>
|
||||
);
|
||||
|
||||
if (isExpandable(folder)) {
|
||||
return {
|
||||
key: folder.key,
|
||||
label,
|
||||
children: renderExpandedContent?.(folder) ?? defaultAssetList(folder),
|
||||
};
|
||||
}
|
||||
|
||||
// A non-expandable row carries no caret and never toggles (`collapsible:
|
||||
// 'icon'` + `showArrow: false`). With a handler the label becomes a button
|
||||
// that navigates; without one the row is an inert header.
|
||||
return {
|
||||
key: folder.key,
|
||||
showArrow: false,
|
||||
collapsible: 'icon',
|
||||
className: onFolderClick
|
||||
? 'folder-row--static folder-row--clickable'
|
||||
: 'folder-row--static',
|
||||
label: onFolderClick ? (
|
||||
<FolderRowButton type="button" onClick={() => onFolderClick(folder)}>
|
||||
{label}
|
||||
</FolderRowButton>
|
||||
) : (
|
||||
label
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// antd would still mark a key active even if its row can't expand; keep the
|
||||
// initial open set to rows that actually have a body.
|
||||
const activeKeys = defaultActiveKeys?.filter(key =>
|
||||
folders.some(folder => folder.key === key && isExpandable(folder)),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledFolderCollapse
|
||||
className={className}
|
||||
items={items}
|
||||
accordion={accordion}
|
||||
expandIconPosition="end"
|
||||
defaultActiveKey={activeKeys}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 type { FC } from 'react';
|
||||
import { Icons } from '../Icons';
|
||||
import type { IconType } from '../Icons/types';
|
||||
import type { FolderAssetType } from './types';
|
||||
|
||||
/** Icon for a collapsed folder. */
|
||||
export const FolderIcon = Icons.FolderOutlined;
|
||||
|
||||
/** Icon for an expanded folder. */
|
||||
export const FolderOpenIcon = Icons.FolderOpenOutlined;
|
||||
|
||||
/** Maps each asset type to its semantic icon. */
|
||||
export const assetTypeIcons: Record<FolderAssetType, FC<IconType>> = {
|
||||
dashboard: Icons.DashboardOutlined,
|
||||
dataset: Icons.TableOutlined,
|
||||
chart: Icons.AreaChartOutlined,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { FolderBreadcrumb } from './FolderBreadcrumb';
|
||||
export { FolderList } from './FolderList';
|
||||
export { FolderIcon, FolderOpenIcon, assetTypeIcons } from './assetIcons';
|
||||
export type {
|
||||
FolderBreadcrumbItem,
|
||||
FolderBreadcrumbProps,
|
||||
Folder,
|
||||
FolderAsset,
|
||||
FolderAssetType,
|
||||
FolderItemType,
|
||||
FolderListProps,
|
||||
} from './types';
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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 type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The kinds of leaf assets that can live inside a folder. Each maps to a
|
||||
* dedicated icon in `assetTypeIcons`.
|
||||
*/
|
||||
export type FolderAssetType = 'dashboard' | 'dataset' | 'chart';
|
||||
|
||||
/**
|
||||
* The kind of a top-level entry in a {@link FolderList}. A folder groups
|
||||
* assets; the asset kinds may also appear as top-level entries in their own
|
||||
* right. The type drives the row's icon and, via `expandableTypes`, whether
|
||||
* the row expands in place or navigates on click.
|
||||
*/
|
||||
export type FolderItemType = 'folder' | FolderAssetType;
|
||||
|
||||
/** A single navigable segment of the folder path. */
|
||||
export interface FolderBreadcrumbItem {
|
||||
/** Stable identifier, passed back to `onClick`. */
|
||||
key: string;
|
||||
/** Label rendered for the segment. */
|
||||
title: ReactNode;
|
||||
/** Marks the segment as a link; called with the item `key` when clicked. */
|
||||
onClick?: (key: string) => void;
|
||||
/** Optional href when the segment should navigate via an anchor. */
|
||||
href?: string;
|
||||
/** Hide the leading folder icon for this segment (defaults to shown). */
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderBreadcrumbProps {
|
||||
/** Ordered path from root to current folder; the last item is the current location. */
|
||||
items: FolderBreadcrumbItem[];
|
||||
/** Separator between segments. Defaults to a `>` chevron. */
|
||||
separator?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A leaf asset (dashboard, dataset or chart) shown inside a folder. */
|
||||
export interface FolderAsset {
|
||||
key: string;
|
||||
name: ReactNode;
|
||||
type: FolderAssetType;
|
||||
}
|
||||
|
||||
/** A top-level entry in a {@link FolderList}, typically a folder of assets. */
|
||||
export interface Folder {
|
||||
key: string;
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Entry kind; controls the icon and whether the row is expandable (see
|
||||
* {@link FolderListProps.expandableTypes}). Defaults to `'folder'`.
|
||||
*/
|
||||
type?: FolderItemType;
|
||||
assets: FolderAsset[];
|
||||
}
|
||||
|
||||
export interface FolderListProps {
|
||||
folders: Folder[];
|
||||
/**
|
||||
* Entry types that expand in place to reveal their contents. A row whose
|
||||
* type is not listed renders as a single, non-collapsible row that calls
|
||||
* `onFolderClick` instead (e.g. to navigate into it). Defaults to `[]`, so
|
||||
* nothing is expandable unless a consumer opts a type in.
|
||||
*/
|
||||
expandableTypes?: FolderItemType[];
|
||||
/** Keys of folders expanded on first render (expandable types only). */
|
||||
defaultActiveKeys?: string[];
|
||||
/** Only allow one folder open at a time. */
|
||||
accordion?: boolean;
|
||||
/** Show the asset-count badge next to each folder title. Defaults to `true`. */
|
||||
showCount?: boolean;
|
||||
/** Invoked when an asset row inside an expanded folder is clicked. */
|
||||
onAssetClick?: (asset: FolderAsset) => void;
|
||||
/**
|
||||
* Invoked when a non-expandable row is clicked, e.g. to open the folder or
|
||||
* asset's own page. Without a handler the row renders as an inert header.
|
||||
*/
|
||||
onFolderClick?: (folder: Folder) => void;
|
||||
/**
|
||||
* Overrides the body shown when an expandable row is open. Receives the
|
||||
* entry and may branch on `folder.type`; return `undefined` to fall back to
|
||||
* the default asset list. Use it to render, say, a chart preview for an
|
||||
* expandable `dashboard` entry instead of a list of assets.
|
||||
*/
|
||||
renderExpandedContent?: (folder: Folder) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -74,7 +74,10 @@ export function transformLinkUri(uri: string): string {
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
const scheme = url
|
||||
.slice(0, colon)
|
||||
.replace(/[\u0000-\u0020]/g, '')
|
||||
.toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
|
||||
@@ -519,7 +519,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +537,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -116,6 +116,20 @@ export {
|
||||
} from './Modal';
|
||||
export * from './ModalTrigger';
|
||||
export { Flex, type FlexProps } from './Flex';
|
||||
export {
|
||||
FolderBreadcrumb,
|
||||
FolderList,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
assetTypeIcons,
|
||||
type FolderBreadcrumbItem,
|
||||
type FolderBreadcrumbProps,
|
||||
type Folder,
|
||||
type FolderAsset,
|
||||
type FolderAssetType,
|
||||
type FolderItemType,
|
||||
type FolderListProps,
|
||||
} from './Folders';
|
||||
export {
|
||||
Form,
|
||||
FormItem,
|
||||
|
||||
@@ -187,9 +187,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
region => region.country_id === d.properties.ISO,
|
||||
);
|
||||
|
||||
hoverPopup.style('display', 'block').html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
|
||||
@@ -164,7 +164,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
processedData = filteredData.map(d => ({
|
||||
...d,
|
||||
radius: radiusScale(Math.sqrt(d.m2)),
|
||||
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
|
||||
fillColor:
|
||||
d.m1 != null ? (colorFn(d.m1) ?? theme.colorBorder) : theme.colorBorder,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ const StyledModal = styled(Modal)`
|
||||
${({ theme }) => theme.sizeUnit * 4}px
|
||||
${({ theme }) => theme.sizeUnit * 3}px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
|
||||
@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
// Only return permission id=10, not id=20
|
||||
return Promise.resolve({
|
||||
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
return Promise.reject(new Error('network error'));
|
||||
}
|
||||
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
|
||||
};
|
||||
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleA.permission_ids.map(pid => ({
|
||||
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleB.permission_ids.map(pid => ({
|
||||
|
||||
@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
|
||||
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
|
||||
import type { QueryEditor } from '../SqlLab/types';
|
||||
|
||||
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||
type LogEventSource =
|
||||
| 'dashboard'
|
||||
| 'embedded_dashboard'
|
||||
| 'explore'
|
||||
| 'sqlLab'
|
||||
| 'slice';
|
||||
|
||||
interface LogEventData {
|
||||
source?: LogEventSource;
|
||||
|
||||
135
superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
Normal file
135
superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Checkbox, Input } from '@superset-ui/core/components';
|
||||
import { StandardModal } from 'src/components/Modal';
|
||||
|
||||
interface CreateFolderModalProps {
|
||||
show: boolean;
|
||||
/** UUID of the folder to create this one under, or null for the root. */
|
||||
parentFolderUuid: string | null;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const ModalContent = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const FormGroup = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function CreateFolderModal({
|
||||
show,
|
||||
parentFolderUuid,
|
||||
onHide,
|
||||
onSuccess,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: CreateFolderModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
addDangerToast(t('Folder name is required'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: '/api/v1/folders/',
|
||||
jsonPayload: {
|
||||
name: name.trim(),
|
||||
folder_type: 'analytics',
|
||||
...(parentFolderUuid ? { parent_uuid: parentFolderUuid } : {}),
|
||||
...(isPrivate ? { is_private: true } : {}),
|
||||
},
|
||||
});
|
||||
addSuccessToast(t('Folder "%s" created', name.trim()));
|
||||
setName('');
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('Error creating folder'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
parentFolderUuid,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
onSuccess,
|
||||
onHide,
|
||||
]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
title={t('Create folder')}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
saveText={t('Create')}
|
||||
saveDisabled={!name.trim()}
|
||||
saveLoading={saving}
|
||||
>
|
||||
<ModalContent>
|
||||
<FormGroup>
|
||||
<label htmlFor="create-folder-name">{t('Name')}</label>
|
||||
<Input
|
||||
id="create-folder-name"
|
||||
placeholder={t('Folder name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
autoFocus
|
||||
/>
|
||||
</FormGroup>
|
||||
{!parentFolderUuid && (
|
||||
<FormGroup>
|
||||
<Checkbox
|
||||
checked={isPrivate}
|
||||
onChange={e => setIsPrivate(e.target.checked)}
|
||||
>
|
||||
{t('Private folder')}
|
||||
</Checkbox>
|
||||
</FormGroup>
|
||||
)}
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
218
superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx
Normal file
218
superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 { useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { Empty, Loading } from '@superset-ui/core/components';
|
||||
import { FacePile, ModifiedInfo, TagsList, TagType } from 'src/components';
|
||||
import { TagTypeEnum } from 'src/components/Tag/TagType';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
const chartRegistry = getChartMetadataRegistry();
|
||||
|
||||
interface Owner {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
type: string;
|
||||
id: number;
|
||||
name: string;
|
||||
url?: string | null;
|
||||
viz_type?: string | null;
|
||||
datasource_name?: string | null;
|
||||
datasource_url?: string | null;
|
||||
owners?: Owner[];
|
||||
changed_on_humanized?: string | null;
|
||||
changed_by?: Owner | null;
|
||||
tags?: TagType[];
|
||||
}
|
||||
|
||||
interface DashboardChartsProps {
|
||||
dashboardId: number;
|
||||
charts: ContentItem[];
|
||||
loading: boolean;
|
||||
onRequest: (dashboardId: number) => void;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
/* Pull grid to the td edges so columns align with table headers */
|
||||
margin: 0 -16px;
|
||||
`;
|
||||
|
||||
const SubHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
padding: ${theme.sizeUnit * 3}px 16px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.8fr 1fr 1fr 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
padding: ${theme.sizeUnit * 3}px 0;
|
||||
font-size: ${theme.fontSize}px;
|
||||
color: ${theme.colorText};
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid ${theme.colorBorderSecondary};
|
||||
}
|
||||
|
||||
/* Match antd table cell padding so content aligns with headers */
|
||||
> * {
|
||||
padding: 0 16px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const NameCell = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 16px !important;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
const Cell = styled.span`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default function DashboardCharts({
|
||||
dashboardId,
|
||||
charts,
|
||||
loading,
|
||||
onRequest,
|
||||
}: DashboardChartsProps) {
|
||||
const theme = useTheme();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colWidths, setColWidths] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
onRequest(dashboardId);
|
||||
}, [dashboardId, onRequest]);
|
||||
|
||||
// Read parent table header widths to align expanded rows with table columns
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const table = el.closest('.ant-table-wrapper')?.querySelector('thead tr');
|
||||
if (!table) return;
|
||||
const ths = Array.from(table.querySelectorAll<HTMLTableCellElement>('th'));
|
||||
// Skip the first th if it's the expand/checkbox column (narrow, no data-col)
|
||||
const dataHeaders = ths.filter(th => th.offsetWidth > 40);
|
||||
if (dataHeaders.length > 0) {
|
||||
setColWidths(dataHeaders.map(th => `${th.offsetWidth}px`).join(' '));
|
||||
}
|
||||
}, [charts.length]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (charts.length === 0) {
|
||||
return <Empty description={t('This dashboard has no charts')} />;
|
||||
}
|
||||
|
||||
const showTags = isFeatureEnabled(FeatureFlag.TaggingSystem);
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef}>
|
||||
<SubHeader>
|
||||
<Icons.AreaChartOutlined iconSize="m" />
|
||||
{t('Charts in the dashboard (%s)', charts.length)}
|
||||
</SubHeader>
|
||||
{charts.map(chart => (
|
||||
<Row
|
||||
key={chart.id}
|
||||
style={colWidths ? { gridTemplateColumns: colWidths } : undefined}
|
||||
>
|
||||
<NameCell>
|
||||
<Icons.AreaChartOutlined
|
||||
iconSize="m"
|
||||
css={{ color: theme.colorInfo, flexShrink: 0 }}
|
||||
/>
|
||||
{chart.url ? <a href={chart.url}>{chart.name}</a> : chart.name}
|
||||
</NameCell>
|
||||
<Cell />
|
||||
<Cell>
|
||||
{chart.viz_type
|
||||
? (chartRegistry.get(chart.viz_type)?.name ?? chart.viz_type)
|
||||
: null}
|
||||
</Cell>
|
||||
<Cell>
|
||||
{chart.datasource_name && chart.datasource_url ? (
|
||||
<a href={chart.datasource_url}>{chart.datasource_name}</a>
|
||||
) : (
|
||||
(chart.datasource_name ?? null)
|
||||
)}
|
||||
</Cell>
|
||||
<Cell>
|
||||
{showTags && chart.tags?.length ? (
|
||||
<TagsList
|
||||
tags={chart.tags.filter(
|
||||
(tag: TagType) =>
|
||||
tag.type === 'TagTypes.custom' ||
|
||||
tag.type === TagTypeEnum.Custom,
|
||||
)}
|
||||
maxTags={3}
|
||||
/>
|
||||
) : null}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<FacePile users={chart.owners || []} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
{chart.changed_on_humanized ? (
|
||||
<ModifiedInfo
|
||||
date={chart.changed_on_humanized}
|
||||
user={chart.changed_by ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
122
superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
Normal file
122
superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Checkbox,
|
||||
DeleteModal,
|
||||
Flex,
|
||||
Typography,
|
||||
type CheckboxChangeEvent,
|
||||
} from '@superset-ui/core/components';
|
||||
|
||||
interface DeleteFolderModalProps {
|
||||
folder: {
|
||||
uuid: string;
|
||||
name: string;
|
||||
asset_count: number;
|
||||
children_count: number;
|
||||
};
|
||||
show: boolean;
|
||||
/** When true, suppresses the per-folder success toast (used in bulk-delete flows). */
|
||||
silent?: boolean;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export default function DeleteFolderModal({
|
||||
folder,
|
||||
show,
|
||||
silent = false,
|
||||
onHide,
|
||||
onSuccess,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: DeleteFolderModalProps) {
|
||||
const theme = useTheme();
|
||||
const [deleteItems, setDeleteItems] = useState(false);
|
||||
|
||||
const isEmpty = folder.asset_count === 0 && folder.children_count === 0;
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
await SupersetClient.delete({
|
||||
endpoint: `/api/v1/folders/${folder.uuid}${deleteItems ? '?archive_items=true' : ''}`,
|
||||
});
|
||||
if (!silent) {
|
||||
addSuccessToast(t('Folder "%s" deleted', folder.name));
|
||||
}
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('Error deleting folder'));
|
||||
}
|
||||
}, [
|
||||
folder,
|
||||
deleteItems,
|
||||
silent,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
onSuccess,
|
||||
onHide,
|
||||
]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setDeleteItems(false);
|
||||
onHide();
|
||||
}, [onHide]);
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
open={show}
|
||||
title={t('Delete folder "%s"', folder.name)}
|
||||
onHide={handleHide}
|
||||
onConfirm={handleConfirm}
|
||||
description={
|
||||
<Flex
|
||||
vertical
|
||||
gap={theme.sizeUnit * 3}
|
||||
style={{ marginTop: -theme.sizeUnit * 2 }}
|
||||
>
|
||||
<Typography.Paragraph style={{ margin: 0 }}>
|
||||
{isEmpty
|
||||
? t("This folder doesn't contain any items.")
|
||||
: t(
|
||||
'This folder contains items. Items will be moved to the parent folder or Analytics page unless you choose to delete everything together.',
|
||||
)}
|
||||
</Typography.Paragraph>
|
||||
{!isEmpty && (
|
||||
<Checkbox
|
||||
checked={deleteItems}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
setDeleteItems(e.target.checked)
|
||||
}
|
||||
>
|
||||
{t('Delete all items in this folder')}
|
||||
</Checkbox>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
AsyncSelect,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
TableSize,
|
||||
type ColumnsType,
|
||||
type SelectOptionsTypePage,
|
||||
type SelectValue,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { StandardModal } from 'src/components/Modal';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
type Permission = 'editor' | 'viewer';
|
||||
|
||||
interface Subject {
|
||||
user_id: number;
|
||||
permission: Permission;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface LocalSubject {
|
||||
key: string;
|
||||
user_id: number;
|
||||
permission: Permission;
|
||||
label: string;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface FolderPermissionsModalProps {
|
||||
folderUuid: string;
|
||||
folderName: string;
|
||||
currentUserId: number;
|
||||
isSubfolder?: boolean;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ value: 'viewer', label: t('Viewer') },
|
||||
{ value: 'editor', label: t('Editor') },
|
||||
];
|
||||
|
||||
const ModalContent = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 3}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorTextLabel};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
function getUserLabel(u: { email?: string | null }): string {
|
||||
return u.email || t('N/A');
|
||||
}
|
||||
|
||||
export default function FolderPermissionsModal({
|
||||
folderUuid,
|
||||
folderName,
|
||||
currentUserId,
|
||||
isSubfolder,
|
||||
show,
|
||||
onHide,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: FolderPermissionsModalProps) {
|
||||
const theme = useTheme();
|
||||
const [serverSubjects, setServerSubjects] = useState<LocalSubject[]>([]);
|
||||
const [localSubjects, setLocalSubjects] = useState<LocalSubject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [memberSearch, setMemberSearch] = useState('');
|
||||
|
||||
const fetchSubjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/folders/${folderUuid}/subjects`,
|
||||
});
|
||||
const subjects = (json.result as Subject[]) || [];
|
||||
const enriched: LocalSubject[] = subjects.map(s => ({
|
||||
key: String(s.user_id),
|
||||
user_id: s.user_id,
|
||||
permission: s.permission,
|
||||
label: getUserLabel(s),
|
||||
isCurrentUser: s.user_id === currentUserId,
|
||||
}));
|
||||
setServerSubjects(enriched);
|
||||
setLocalSubjects(enriched);
|
||||
} catch {
|
||||
addDangerToast(t('Error loading permissions'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [folderUuid, currentUserId, addDangerToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchSubjects();
|
||||
setMemberSearch('');
|
||||
}
|
||||
}, [show, fetchSubjects]);
|
||||
|
||||
const handleAddUser = useCallback(
|
||||
(selected: SelectValue) => {
|
||||
if (!selected || typeof selected !== 'object') return;
|
||||
const item = selected as { value: number; label: string };
|
||||
const userId = Number(item.value);
|
||||
if (!userId || Number.isNaN(userId)) return;
|
||||
setLocalSubjects(prev => {
|
||||
if (prev.some(s => s.user_id === userId)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
key: String(userId),
|
||||
user_id: userId,
|
||||
permission: 'viewer' as Permission,
|
||||
label: String(item.label),
|
||||
isCurrentUser: userId === currentUserId,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[currentUserId],
|
||||
);
|
||||
|
||||
const handleChangePermission = useCallback(
|
||||
(userId: number, permission: Permission) => {
|
||||
if (userId === currentUserId) return;
|
||||
setLocalSubjects(prev =>
|
||||
prev.map(s => (s.user_id === userId ? { ...s, permission } : s)),
|
||||
);
|
||||
},
|
||||
[currentUserId],
|
||||
);
|
||||
|
||||
const handleRemoveSubject = useCallback(
|
||||
(userId: number) => {
|
||||
if (userId === currentUserId) return;
|
||||
setLocalSubjects(prev => prev.filter(s => s.user_id !== userId));
|
||||
},
|
||||
[currentUserId],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const serverMap = new Map(
|
||||
serverSubjects.map(s => [s.user_id, s.permission]),
|
||||
);
|
||||
const localMap = new Map(
|
||||
localSubjects.map(s => [s.user_id, s.permission]),
|
||||
);
|
||||
|
||||
const calls: Promise<unknown>[] = [];
|
||||
|
||||
for (const [userId, permission] of localMap) {
|
||||
if (!serverMap.has(userId)) {
|
||||
calls.push(
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/folders/${folderUuid}/subjects`,
|
||||
jsonPayload: { user_id: userId, permission },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userId] of serverMap) {
|
||||
if (!localMap.has(userId)) {
|
||||
calls.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userId, permission] of localMap) {
|
||||
const serverPerm = serverMap.get(userId);
|
||||
if (serverPerm && serverPerm !== permission) {
|
||||
calls.push(
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
|
||||
jsonPayload: { permission },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(calls);
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
addDangerToast(t('%s permission update(s) failed', failures.length));
|
||||
} else {
|
||||
addSuccessToast(t('Permissions updated'));
|
||||
}
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('Error updating permissions'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
folderUuid,
|
||||
serverSubjects,
|
||||
localSubjects,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
onHide,
|
||||
]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (serverSubjects.length !== localSubjects.length) return true;
|
||||
const serverMap = new Map(
|
||||
serverSubjects.map(s => [s.user_id, s.permission]),
|
||||
);
|
||||
return localSubjects.some(s => serverMap.get(s.user_id) !== s.permission);
|
||||
}, [serverSubjects, localSubjects]);
|
||||
|
||||
const fetchAvailableUsers = useCallback(
|
||||
async (
|
||||
search: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<SelectOptionsTypePage> => {
|
||||
const params = new URLSearchParams({
|
||||
q: search,
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/folders/${folderUuid}/available-users?${params}`,
|
||||
});
|
||||
const results = json?.result || [];
|
||||
return {
|
||||
data: results.map((u: { id: number; email: string }) => ({
|
||||
value: u.id,
|
||||
label: getUserLabel(u),
|
||||
})),
|
||||
totalCount: json?.count ?? 0,
|
||||
};
|
||||
},
|
||||
[folderUuid],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!memberSearch) return localSubjects;
|
||||
const q = memberSearch.toLowerCase();
|
||||
return localSubjects.filter(s => s.label.toLowerCase().includes(q));
|
||||
}, [localSubjects, memberSearch]);
|
||||
|
||||
const columns: ColumnsType<LocalSubject> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('User'),
|
||||
dataIndex: 'label',
|
||||
key: 'label',
|
||||
render: (label: string, record: LocalSubject) => (
|
||||
<span>
|
||||
{label}
|
||||
{record.isCurrentUser && (
|
||||
<span css={{ opacity: 0.5, marginLeft: theme.sizeUnit }}>
|
||||
({t('you')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Permission'),
|
||||
dataIndex: 'permission',
|
||||
key: 'permission',
|
||||
width: 130,
|
||||
render: (permission: Permission, record: LocalSubject) => (
|
||||
<Select
|
||||
ariaLabel={t('Permission')}
|
||||
options={PERMISSION_OPTIONS}
|
||||
value={permission}
|
||||
disabled={record.isCurrentUser}
|
||||
onChange={(val: Permission) =>
|
||||
handleChangePermission(record.user_id, val)
|
||||
}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.ant-modal-content') || document.body
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 40,
|
||||
render: (_: unknown, record: LocalSubject) =>
|
||||
record.isCurrentUser ? null : (
|
||||
<Icons.DeleteOutlined
|
||||
iconSize="m"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleRemoveSubject(record.user_id)}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
color: ${theme.colorIcon};
|
||||
&:hover {
|
||||
color: ${theme.colorError};
|
||||
}
|
||||
`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[theme, handleChangePermission, handleRemoveSubject],
|
||||
);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
title={t('Manage permissions — %s', folderName)}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
saveText={t('Save')}
|
||||
saveLoading={saving}
|
||||
saveDisabled={!hasChanges}
|
||||
contentLoading={loading}
|
||||
>
|
||||
<ModalContent>
|
||||
{isSubfolder && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'Changing permissions here will stop this folder from automatically inheriting updates from its parent. You can re-sync later from the actions menu.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<SectionLabel>{t('Add user')}</SectionLabel>
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Search users')}
|
||||
placeholder={t('Search for a user…')}
|
||||
options={fetchAvailableUsers}
|
||||
onChange={handleAddUser}
|
||||
value={undefined}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.ant-modal-content') || document.body
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t('Members')} ({localSubjects.length})
|
||||
</SectionLabel>
|
||||
<Input
|
||||
placeholder={t('Search members…')}
|
||||
value={memberSearch}
|
||||
onChange={e => setMemberSearch(e.target.value)}
|
||||
allowClear
|
||||
css={{ marginBottom: theme.sizeUnit * 2 }}
|
||||
/>
|
||||
<Table<LocalSubject>
|
||||
data={filtered}
|
||||
columns={columns}
|
||||
usePagination
|
||||
defaultPageSize={10}
|
||||
size={TableSize.Small}
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
129
superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
Normal file
129
superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Input } from '@superset-ui/core/components';
|
||||
import { StandardModal } from 'src/components/Modal';
|
||||
|
||||
interface RenameFolderModalProps {
|
||||
folderUuid: string;
|
||||
currentName: string;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const ModalContent = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const FormGroup = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function RenameFolderModal({
|
||||
folderUuid,
|
||||
currentName,
|
||||
show,
|
||||
onHide,
|
||||
onSuccess,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: RenameFolderModalProps) {
|
||||
const [name, setName] = useState(currentName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) setName(currentName);
|
||||
}, [show, currentName]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
addDangerToast(t('Folder name is required'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === currentName) {
|
||||
onHide();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/folders/${folderUuid}`,
|
||||
jsonPayload: { name: trimmed },
|
||||
});
|
||||
addSuccessToast(t('Folder renamed to "%s"', trimmed));
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('Error renaming folder'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
currentName,
|
||||
folderUuid,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
onSuccess,
|
||||
onHide,
|
||||
]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
title={t('Rename folder')}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
saveText={t('Save')}
|
||||
saveDisabled={!name.trim() || name.trim() === currentName}
|
||||
saveLoading={saving}
|
||||
>
|
||||
<ModalContent>
|
||||
<FormGroup>
|
||||
<label htmlFor="rename-folder-name">{t('Name')}</label>
|
||||
<Input
|
||||
id="rename-folder-name"
|
||||
placeholder={t('Folder name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
autoFocus
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
871
superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
Normal file
871
superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
Normal file
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* 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 { type UIEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Constants,
|
||||
Flex,
|
||||
Input,
|
||||
List,
|
||||
Loading,
|
||||
} from '@superset-ui/core/components';
|
||||
import { TreeSelect } from '@superset-ui/core/components/TreeSelect';
|
||||
import { StandardModal, MODAL_LARGE_WIDTH } from 'src/components/Modal';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
|
||||
type ItemType = 'folder' | 'chart' | 'dashboard';
|
||||
|
||||
interface TransferItem {
|
||||
key: string;
|
||||
type: ItemType;
|
||||
id: number;
|
||||
uuid?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FolderOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
parent_uuid: string | null;
|
||||
}
|
||||
|
||||
interface TransferModalProps {
|
||||
currentFolderUuid: string | null;
|
||||
currentFolderName: string;
|
||||
preSelectedKeys?: string[];
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SCROLL_THRESHOLD = 0.7;
|
||||
|
||||
const PanelContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
align-items: start;
|
||||
min-height: 400px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Panel = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
min-width: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
min-width: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const BulkActionsBar = styled(Flex)`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit}px 0;
|
||||
border-bottom: 1px solid ${theme.colorSplit};
|
||||
.superset-button {
|
||||
font-family: inherit;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.superset-button:first-of-type {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.superset-button:last-of-type {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextLabel};
|
||||
`}
|
||||
`;
|
||||
|
||||
const PanelCount = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
`;
|
||||
|
||||
const ArrowColumn = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
align-self: center;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ModalContent = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ScrollableList = styled.div`
|
||||
${({ theme }) => css`
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid ${theme.colorBorderSecondary};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmptyState = styled.p`
|
||||
${({ theme }) => css`
|
||||
opacity: 0.5;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
text-align: center;
|
||||
`}
|
||||
`;
|
||||
|
||||
function toTransferItem(item: {
|
||||
type: string;
|
||||
id: number;
|
||||
uuid?: string | null;
|
||||
name: string;
|
||||
}): TransferItem {
|
||||
return {
|
||||
key: `${item.type}-${item.id}`,
|
||||
type: item.type as ItemType,
|
||||
id: item.id,
|
||||
uuid: item.uuid,
|
||||
name: item.name,
|
||||
};
|
||||
}
|
||||
|
||||
function ItemIcon({ type }: { type: ItemType }) {
|
||||
if (type === 'chart') return <Icons.AreaChartOutlined iconSize="m" />;
|
||||
if (type === 'dashboard') return <Icons.AppstoreOutlined iconSize="m" />;
|
||||
return <Icons.FolderOutlined iconSize="m" />;
|
||||
}
|
||||
|
||||
async function fetchPage(
|
||||
folderUuid: string | null,
|
||||
page: number,
|
||||
search?: string,
|
||||
): Promise<{ items: TransferItem[]; total: number }> {
|
||||
const base = folderUuid
|
||||
? `/api/v1/folders/${folderUuid}/assets`
|
||||
: '/api/v1/folders/assets';
|
||||
const risonParams: Record<string, unknown> = {
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
order_column: 'changed_on',
|
||||
order_direction: 'desc',
|
||||
};
|
||||
if (search) {
|
||||
risonParams.filters = [{ col: 'name', opr: 'ct', value: search }];
|
||||
}
|
||||
const q = rison.encode_uri(risonParams);
|
||||
const folderParam = folderUuid ? '' : 'folder_type=analytics&';
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `${base}?${folderParam}q=${q}`,
|
||||
});
|
||||
const results =
|
||||
(response.json.result as {
|
||||
type: string;
|
||||
id: number;
|
||||
uuid?: string | null;
|
||||
name: string;
|
||||
}[]) || [];
|
||||
return {
|
||||
items: results.map(toTransferItem),
|
||||
total: (response.json.count as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TransferModal({
|
||||
currentFolderUuid,
|
||||
currentFolderName,
|
||||
preSelectedKeys,
|
||||
show,
|
||||
onHide,
|
||||
onSuccess,
|
||||
}: TransferModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const theme = useTheme();
|
||||
const [leftItems, setLeftItems] = useState<TransferItem[]>([]);
|
||||
const [leftTotal, setLeftTotal] = useState(0);
|
||||
const [leftPage, setLeftPage] = useState(0);
|
||||
const [rightItems, setRightItems] = useState<TransferItem[]>([]);
|
||||
const [rightTotal, setRightTotal] = useState(0);
|
||||
const [rightPage, setRightPage] = useState(0);
|
||||
const [leftSelected, setLeftSelected] = useState<Set<string>>(new Set());
|
||||
const [rightSelected, setRightSelected] = useState<Set<string>>(new Set());
|
||||
const [leftSearch, setLeftSearch] = useState('');
|
||||
const [rightSearch, setRightSearch] = useState('');
|
||||
const [targetFolderUuid, setTargetFolderUuid] = useState<string | null>(null);
|
||||
const [allFolders, setAllFolders] = useState<FolderOption[]>([]);
|
||||
const [leftLoading, setLeftLoading] = useState(false);
|
||||
const [rightLoading, setRightLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [originalRightItems, setOriginalRightItems] = useState<
|
||||
Map<string, TransferItem>
|
||||
>(() => new Map());
|
||||
|
||||
// Fetch left (source) first page + folder list on open
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
setLeftSearch('');
|
||||
setRightSearch('');
|
||||
setLeftSelected(new Set());
|
||||
setRightSelected(new Set());
|
||||
setRightItems([]);
|
||||
setRightTotal(0);
|
||||
setRightPage(0);
|
||||
setTargetFolderUuid(null);
|
||||
setOriginalRightItems(new Map());
|
||||
setLeftLoading(true);
|
||||
|
||||
Promise.all([
|
||||
fetchPage(currentFolderUuid, 0),
|
||||
SupersetClient.get({
|
||||
endpoint: '/api/v1/folders/?folder_type=analytics',
|
||||
}),
|
||||
])
|
||||
.then(([{ items, total }, foldersRes]) => {
|
||||
setLeftItems(items);
|
||||
setLeftTotal(total);
|
||||
setLeftPage(0);
|
||||
const folders = (
|
||||
(foldersRes.json.result as FolderOption[]) || []
|
||||
).filter(f => f.uuid !== currentFolderUuid);
|
||||
setAllFolders(folders);
|
||||
if (preSelectedKeys?.length) {
|
||||
setLeftSelected(new Set(preSelectedKeys));
|
||||
}
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error loading data')))
|
||||
.finally(() => setLeftLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show, currentFolderUuid, addDangerToast]);
|
||||
|
||||
// Fetch right (target) first page when target changes
|
||||
useEffect(() => {
|
||||
if (!show || !targetFolderUuid) {
|
||||
setRightItems([]);
|
||||
setRightTotal(0);
|
||||
setRightPage(0);
|
||||
setOriginalRightItems(new Map());
|
||||
return;
|
||||
}
|
||||
setRightSearch('');
|
||||
setRightSelected(new Set());
|
||||
setRightLoading(true);
|
||||
const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
|
||||
fetchPage(apiUuid, 0)
|
||||
.then(({ items, total }) => {
|
||||
setRightItems(items);
|
||||
setRightTotal(total);
|
||||
setRightPage(0);
|
||||
setOriginalRightItems(new Map(items.map(i => [i.key, i])));
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error loading folder contents')))
|
||||
.finally(() => setRightLoading(false));
|
||||
}, [show, targetFolderUuid, addDangerToast]);
|
||||
|
||||
// Debounced server-side search for left panel
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const timer = setTimeout(() => {
|
||||
setLeftLoading(true);
|
||||
fetchPage(currentFolderUuid, 0, leftSearch || undefined)
|
||||
.then(({ items, total }) => {
|
||||
setLeftItems(items);
|
||||
setLeftTotal(total);
|
||||
setLeftPage(0);
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error searching items')))
|
||||
.finally(() => setLeftLoading(false));
|
||||
}, Constants.SLOW_DEBOUNCE);
|
||||
return () => clearTimeout(timer);
|
||||
}, [leftSearch, show, currentFolderUuid, addDangerToast]);
|
||||
|
||||
// Debounced server-side search for right panel
|
||||
useEffect(() => {
|
||||
if (!show || !targetFolderUuid) return;
|
||||
const timer = setTimeout(() => {
|
||||
const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
|
||||
setRightLoading(true);
|
||||
fetchPage(apiUuid, 0, rightSearch || undefined)
|
||||
.then(({ items, total }) => {
|
||||
setRightItems(items);
|
||||
setRightTotal(total);
|
||||
setRightPage(0);
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error searching items')))
|
||||
.finally(() => setRightLoading(false));
|
||||
}, Constants.SLOW_DEBOUNCE);
|
||||
return () => clearTimeout(timer);
|
||||
}, [rightSearch, show, targetFolderUuid, addDangerToast]);
|
||||
|
||||
// Scroll handler — fetch next page when near bottom (AsyncSelect pattern)
|
||||
const handleLeftScroll = useCallback(
|
||||
(e: UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const nearBottom =
|
||||
el.scrollTop > (el.scrollHeight - el.offsetHeight) * SCROLL_THRESHOLD;
|
||||
const hasMore = (leftPage + 1) * PAGE_SIZE < leftTotal;
|
||||
if (!leftLoading && hasMore && nearBottom) {
|
||||
const nextPage = leftPage + 1;
|
||||
setLeftLoading(true);
|
||||
fetchPage(currentFolderUuid, nextPage, leftSearch || undefined)
|
||||
.then(({ items }) => {
|
||||
setLeftItems(prev => {
|
||||
const existing = new Set(prev.map(i => i.key));
|
||||
return [...prev, ...items.filter(i => !existing.has(i.key))];
|
||||
});
|
||||
setLeftPage(nextPage);
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error loading more items')))
|
||||
.finally(() => setLeftLoading(false));
|
||||
}
|
||||
},
|
||||
[
|
||||
leftPage,
|
||||
leftTotal,
|
||||
leftLoading,
|
||||
leftSearch,
|
||||
currentFolderUuid,
|
||||
addDangerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRightScroll = useCallback(
|
||||
(e: UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const nearBottom =
|
||||
el.scrollTop > (el.scrollHeight - el.offsetHeight) * SCROLL_THRESHOLD;
|
||||
const hasMore = (rightPage + 1) * PAGE_SIZE < rightTotal;
|
||||
if (!rightLoading && hasMore && nearBottom) {
|
||||
const nextPage = rightPage + 1;
|
||||
const apiUuid =
|
||||
targetFolderUuid === '__root__' ? null : targetFolderUuid;
|
||||
setRightLoading(true);
|
||||
fetchPage(apiUuid, nextPage, rightSearch || undefined)
|
||||
.then(({ items }) => {
|
||||
setRightItems(prev => {
|
||||
const existing = new Set(prev.map(i => i.key));
|
||||
return [...prev, ...items.filter(i => !existing.has(i.key))];
|
||||
});
|
||||
setRightPage(nextPage);
|
||||
})
|
||||
.catch(() => addDangerToast(t('Error loading more items')))
|
||||
.finally(() => setRightLoading(false));
|
||||
}
|
||||
},
|
||||
[
|
||||
rightPage,
|
||||
rightTotal,
|
||||
rightLoading,
|
||||
rightSearch,
|
||||
targetFolderUuid,
|
||||
addDangerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const toggleLeftSelect = useCallback((key: string) => {
|
||||
setLeftSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleRightSelect = useCallback((key: string) => {
|
||||
setRightSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const moveRight = useCallback(() => {
|
||||
if (!leftSelected.size) return;
|
||||
const moving = leftItems.filter(i => leftSelected.has(i.key));
|
||||
setLeftItems(prev => prev.filter(i => !leftSelected.has(i.key)));
|
||||
setRightItems(prev => [...prev, ...moving]);
|
||||
setLeftSelected(new Set());
|
||||
}, [leftSelected, leftItems]);
|
||||
|
||||
const moveLeft = useCallback(() => {
|
||||
if (!rightSelected.size) return;
|
||||
const moving = rightItems.filter(i => rightSelected.has(i.key));
|
||||
setRightItems(prev => prev.filter(i => !rightSelected.has(i.key)));
|
||||
setLeftItems(prev => [...prev, ...moving]);
|
||||
setRightSelected(new Set());
|
||||
}, [rightSelected, rightItems]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
// Check if any items were moved TO the target (exist in right but not originally)
|
||||
const addedToRight = rightItems.some(i => !originalRightItems.has(i.key));
|
||||
// Check if any items were moved FROM the target (originally in right but no longer)
|
||||
const rightKeysNow = new Set(rightItems.map(i => i.key));
|
||||
const removedFromRight = Array.from(originalRightItems.keys()).some(
|
||||
key => !rightKeysNow.has(key),
|
||||
);
|
||||
return addedToRight || removedFromRight;
|
||||
}, [rightItems, originalRightItems]);
|
||||
|
||||
const handleDone = useCallback(async () => {
|
||||
if (!targetFolderUuid) return;
|
||||
const targetApiUuid =
|
||||
targetFolderUuid === '__root__' ? null : targetFolderUuid;
|
||||
setSaving(true);
|
||||
try {
|
||||
const rightKeysNow = new Set(rightItems.map(i => i.key));
|
||||
const calls: Promise<unknown>[] = [];
|
||||
|
||||
const addedToTarget = rightItems.filter(
|
||||
i => !originalRightItems.has(i.key),
|
||||
);
|
||||
const assetsToAdd = addedToTarget.filter(i => i.type !== 'folder');
|
||||
const foldersToAdd = addedToTarget.filter(i => i.type === 'folder');
|
||||
|
||||
if (assetsToAdd.length && targetApiUuid) {
|
||||
// Move assets into target folder
|
||||
calls.push(
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/folders/${targetApiUuid}/assets`,
|
||||
jsonPayload: {
|
||||
assets: assetsToAdd.map(i => ({ type: i.type, id: i.id })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (assetsToAdd.length && !targetApiUuid && currentFolderUuid) {
|
||||
// Move assets to root — remove them from source folder
|
||||
const q = rison.encode(
|
||||
assetsToAdd.map(i => ({ type: i.type, id: i.id })),
|
||||
);
|
||||
calls.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/folders/${currentFolderUuid}/assets?q=${q}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const f of foldersToAdd) {
|
||||
if (f.uuid) {
|
||||
calls.push(
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/folders/${f.uuid}`,
|
||||
jsonPayload: { parent_uuid: targetApiUuid },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, item] of originalRightItems) {
|
||||
if (!rightKeysNow.has(key)) {
|
||||
if (item.type === 'folder' && item.uuid) {
|
||||
calls.push(
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/folders/${item.uuid}`,
|
||||
jsonPayload: { parent_uuid: currentFolderUuid },
|
||||
}),
|
||||
);
|
||||
} else if (currentFolderUuid) {
|
||||
calls.push(
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/folders/${currentFolderUuid}/assets`,
|
||||
jsonPayload: {
|
||||
assets: [{ type: item.type, id: item.id }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(calls);
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length) {
|
||||
addDangerToast(t('%s item(s) failed to move', failures.length));
|
||||
} else {
|
||||
addSuccessToast(t('Items moved successfully'));
|
||||
}
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('Error moving items'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
targetFolderUuid,
|
||||
currentFolderUuid,
|
||||
rightItems,
|
||||
originalRightItems,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
onSuccess,
|
||||
onHide,
|
||||
]);
|
||||
|
||||
const filteredLeft = useMemo(() => {
|
||||
if (!targetFolderUuid) return leftItems;
|
||||
const rightKeys = new Set(rightItems.map(i => i.key));
|
||||
return leftItems.filter(
|
||||
i =>
|
||||
!(i.type === 'folder' && i.uuid === targetFolderUuid) &&
|
||||
!rightKeys.has(i.key),
|
||||
);
|
||||
}, [leftItems, targetFolderUuid, rightItems]);
|
||||
|
||||
const filteredRight = useMemo(
|
||||
() =>
|
||||
rightItems.filter(
|
||||
i => !(i.type === 'folder' && i.uuid === currentFolderUuid),
|
||||
),
|
||||
[rightItems, currentFolderUuid],
|
||||
);
|
||||
|
||||
const leftSelectableCount = filteredLeft.filter(
|
||||
(i: TransferItem) => !leftSelected.has(i.key),
|
||||
).length;
|
||||
const leftDeselectableCount = filteredLeft.filter((i: TransferItem) =>
|
||||
leftSelected.has(i.key),
|
||||
).length;
|
||||
const rightSelectableCount = filteredRight.filter(
|
||||
(i: TransferItem) => !rightSelected.has(i.key),
|
||||
).length;
|
||||
const rightDeselectableCount = filteredRight.filter((i: TransferItem) =>
|
||||
rightSelected.has(i.key),
|
||||
).length;
|
||||
|
||||
const folderTreeData = useMemo(() => {
|
||||
// Collect UUIDs of selected folders — they can't be their own target.
|
||||
const selectedFolderUuids = new Set(
|
||||
leftItems
|
||||
.filter(i => i.type === 'folder' && i.uuid && leftSelected.has(i.key))
|
||||
.map(i => i.uuid as string),
|
||||
);
|
||||
|
||||
// BFS: also exclude all descendants so a parent can't be moved into a child.
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
for (const f of allFolders) {
|
||||
if (f.parent_uuid) {
|
||||
const siblings = childrenMap.get(f.parent_uuid) ?? [];
|
||||
siblings.push(f.uuid);
|
||||
childrenMap.set(f.parent_uuid, siblings);
|
||||
}
|
||||
}
|
||||
const excluded = new Set(selectedFolderUuids);
|
||||
const queue = [...selectedFolderUuids];
|
||||
while (queue.length) {
|
||||
const uuid = queue.shift()!;
|
||||
for (const childUuid of childrenMap.get(uuid) ?? []) {
|
||||
if (!excluded.has(childUuid)) {
|
||||
excluded.add(childUuid);
|
||||
queue.push(childUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eligible = allFolders.filter(f => !excluded.has(f.uuid));
|
||||
|
||||
// Build tree from flat list
|
||||
type TreeNode = {
|
||||
value: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
};
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
for (const f of eligible) {
|
||||
nodeMap.set(f.uuid, { value: f.uuid, title: f.name, children: [] });
|
||||
}
|
||||
const roots: TreeNode[] = [];
|
||||
for (const f of eligible) {
|
||||
const node = nodeMap.get(f.uuid)!;
|
||||
const parentNode = f.parent_uuid ? nodeMap.get(f.parent_uuid) : null;
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend root option when inside a folder
|
||||
if (currentFolderUuid) {
|
||||
roots.unshift({
|
||||
value: '__root__',
|
||||
title: t('Root (Analytics)'),
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return roots;
|
||||
}, [allFolders, currentFolderUuid, leftItems, leftSelected]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
title={t('Move items')}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleDone}
|
||||
saveText={t('Done')}
|
||||
saveLoading={saving}
|
||||
saveDisabled={!hasChanges || !targetFolderUuid}
|
||||
width={MODAL_LARGE_WIDTH}
|
||||
>
|
||||
<ModalContent>
|
||||
<PanelContainer>
|
||||
<Panel>
|
||||
<Field>
|
||||
<FieldLabel>{t('Source folder')}</FieldLabel>
|
||||
<Input
|
||||
value={currentFolderName || t('Root (Analytics)')}
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<PanelCount>
|
||||
{leftSelected.size > 0
|
||||
? t('%s/%s items selected', leftSelected.size, leftTotal)
|
||||
: t('0 items selected')}
|
||||
</PanelCount>
|
||||
<Input
|
||||
placeholder={t('Search here')}
|
||||
value={leftSearch}
|
||||
onChange={e => setLeftSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<BulkActionsBar gap={theme.sizeUnit}>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={leftSelectableCount === 0}
|
||||
onClick={() =>
|
||||
setLeftSelected(
|
||||
(prev: Set<string>) =>
|
||||
new Set([
|
||||
...prev,
|
||||
...filteredLeft.map((i: TransferItem) => i.key),
|
||||
]),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Select all (%s)', leftSelectableCount)}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={leftDeselectableCount === 0}
|
||||
onClick={() =>
|
||||
setLeftSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
filteredLeft.forEach((i: TransferItem) =>
|
||||
next.delete(i.key),
|
||||
);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Clear (%s)', leftDeselectableCount)}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
{leftLoading && !leftItems.length ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<ScrollableList onScroll={handleLeftScroll}>
|
||||
<List
|
||||
dataSource={filteredLeft}
|
||||
locale={{ emptyText: t('No items') }}
|
||||
renderItem={(item: TransferItem) => (
|
||||
<List.Item
|
||||
key={item.key}
|
||||
onClick={() => toggleLeftSelect(item.key)}
|
||||
css={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Flex align="center" gap={theme.sizeUnit * 2}>
|
||||
<Checkbox
|
||||
css={{ paddingLeft: theme.sizeUnit * 2 }}
|
||||
checked={leftSelected.has(item.key)}
|
||||
/>
|
||||
<ItemIcon type={item.type} />
|
||||
{item.name}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</ScrollableList>
|
||||
)}
|
||||
</Field>
|
||||
</Panel>
|
||||
|
||||
<ArrowColumn>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
disabled={!leftSelected.size || !targetFolderUuid}
|
||||
onClick={moveRight}
|
||||
>
|
||||
<Icons.RightOutlined iconSize="m" />
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
disabled={!rightSelected.size}
|
||||
onClick={moveLeft}
|
||||
css={css`
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
>
|
||||
<Icons.LeftOutlined iconSize="m" />
|
||||
</Button>
|
||||
</ArrowColumn>
|
||||
|
||||
<Panel>
|
||||
<Field>
|
||||
<FieldLabel>{t('Destination folder')}</FieldLabel>
|
||||
<TreeSelect
|
||||
aria-label={t('Target folder')}
|
||||
placeholder={t('Select a location…')}
|
||||
treeData={folderTreeData}
|
||||
value={targetFolderUuid}
|
||||
onChange={(val: string) => setTargetFolderUuid(val)}
|
||||
treeDefaultExpandAll
|
||||
showSearch
|
||||
filterTreeNode={(input, node) =>
|
||||
(node?.title as string)
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase()) ?? false
|
||||
}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.ant-modal-content') || document.body
|
||||
}
|
||||
css={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<PanelCount>
|
||||
{rightSelected.size > 0
|
||||
? t('%s/%s items selected', rightSelected.size, rightTotal)
|
||||
: t('0 items selected')}
|
||||
</PanelCount>
|
||||
<Input
|
||||
placeholder={t('Search here')}
|
||||
value={rightSearch}
|
||||
onChange={e => setRightSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<BulkActionsBar gap={theme.sizeUnit}>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={rightSelectableCount === 0}
|
||||
onClick={() =>
|
||||
setRightSelected(
|
||||
(prev: Set<string>) =>
|
||||
new Set([
|
||||
...prev,
|
||||
...filteredRight.map((i: TransferItem) => i.key),
|
||||
]),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Select all (%s)', rightSelectableCount)}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={rightDeselectableCount === 0}
|
||||
onClick={() =>
|
||||
setRightSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
filteredRight.forEach((i: TransferItem) =>
|
||||
next.delete(i.key),
|
||||
);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Clear (%s)', rightDeselectableCount)}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
{!targetFolderUuid ? (
|
||||
<EmptyState>
|
||||
{t('Select a location to see its contents')}
|
||||
</EmptyState>
|
||||
) : rightLoading && !rightItems.length ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<ScrollableList onScroll={handleRightScroll}>
|
||||
<List
|
||||
dataSource={filteredRight}
|
||||
locale={{ emptyText: t('No items') }}
|
||||
renderItem={(item: TransferItem) => (
|
||||
<List.Item
|
||||
key={item.key}
|
||||
onClick={() => toggleRightSelect(item.key)}
|
||||
css={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Flex align="center" gap={theme.sizeUnit * 2}>
|
||||
<Checkbox
|
||||
css={{ paddingLeft: theme.sizeUnit * 2 }}
|
||||
checked={rightSelected.has(item.key)}
|
||||
/>
|
||||
<ItemIcon type={item.type} />
|
||||
{item.name}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</ScrollableList>
|
||||
)}
|
||||
</Field>
|
||||
</Panel>
|
||||
</PanelContainer>
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
1420
superset-frontend/src/pages/AnalyticsList/index.tsx
Normal file
1420
superset-frontend/src/pages/AnalyticsList/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
||||
ComponentProps,
|
||||
LazyExoticComponent,
|
||||
} from 'react';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
@@ -35,6 +36,11 @@ const ChartCreation = lazy(
|
||||
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
|
||||
);
|
||||
|
||||
const AnalyticsList = lazy(
|
||||
() =>
|
||||
import(/* webpackChunkName: "AnalyticsList" */ 'src/pages/AnalyticsList'),
|
||||
);
|
||||
|
||||
const AnnotationLayerList = lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -235,6 +241,10 @@ export const routes: Routes = [
|
||||
path: '/chart/add',
|
||||
Component: ChartCreation,
|
||||
},
|
||||
{
|
||||
path: '/analytics/:folderUuid?/',
|
||||
Component: AnalyticsList,
|
||||
},
|
||||
{
|
||||
path: '/chart/list/',
|
||||
Component: ChartList,
|
||||
@@ -379,20 +389,15 @@ if (authRegistrationEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
const frontEndRoutes: Record<string, boolean> = routes
|
||||
.map(r => r.path)
|
||||
.reduce(
|
||||
(acc, curr) => ({
|
||||
...acc,
|
||||
[curr]: true,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const frontEndRoutePaths = routes.map(r => r.path);
|
||||
|
||||
export const isFrontendRoute = (path?: string): boolean => {
|
||||
if (path) {
|
||||
const basePath = path.split(/[?#]/)[0]; // strip out query params and link bookmarks
|
||||
return !!frontEndRoutes[basePath];
|
||||
const basePath = path.split(/[?#]/)[0];
|
||||
return frontEndRoutePaths.some(
|
||||
routePath =>
|
||||
matchPath(basePath, { path: routePath, exact: true }) !== null,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
16
superset/commands/folder/__init__.py
Normal file
16
superset/commands/folder/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
135
superset/commands/folder/assets.py
Normal file
135
superset/commands/folder/assets.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import ValidationError
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
from superset.commands.folder.exceptions import (
|
||||
FolderAssetNotFoundValidationError,
|
||||
FolderAssetTypeValidationError,
|
||||
FolderForbiddenError,
|
||||
FolderInvalidError,
|
||||
FolderNotFoundError,
|
||||
FolderUpdateFailedError,
|
||||
)
|
||||
from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.models import Folder
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO
|
||||
|
||||
|
||||
def _validate_folder_assets(
|
||||
folder_id_or_uuid: str, assets: list[dict[str, Any]]
|
||||
) -> Folder:
|
||||
"""Validate folder existence, editor permissions, and asset references."""
|
||||
model = FolderDAO.find_by_id_or_uuid(folder_id_or_uuid)
|
||||
if not model:
|
||||
raise FolderNotFoundError()
|
||||
|
||||
if not security_manager.is_admin():
|
||||
user_id = get_user_id()
|
||||
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
|
||||
user_id, model.id
|
||||
):
|
||||
raise FolderForbiddenError()
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
allowed = set(asset_types_for_folder_type(model.folder_type))
|
||||
is_admin = security_manager.is_admin()
|
||||
for asset in assets:
|
||||
asset_type = asset["type"]
|
||||
if asset_type not in allowed:
|
||||
exceptions.append(
|
||||
FolderAssetTypeValidationError(asset_type, model.folder_type)
|
||||
)
|
||||
elif not FolderDAO.asset_exists(asset_type, asset["id"]):
|
||||
exceptions.append(
|
||||
FolderAssetNotFoundValidationError(asset_type, asset["id"])
|
||||
)
|
||||
elif not is_admin:
|
||||
from superset.extensions import db
|
||||
|
||||
config = ASSET_TYPE_CONFIGS[asset_type]
|
||||
asset_obj = db.session.get(config.model, asset["id"])
|
||||
if asset_obj:
|
||||
try:
|
||||
if asset_type == "dashboard":
|
||||
security_manager.raise_for_access(dashboard=asset_obj)
|
||||
elif asset_type == "chart":
|
||||
security_manager.raise_for_access(chart=asset_obj)
|
||||
except Exception as ex:
|
||||
raise FolderForbiddenError() from ex
|
||||
|
||||
if exceptions:
|
||||
raise FolderInvalidError(exceptions=exceptions)
|
||||
|
||||
return model
|
||||
|
||||
|
||||
class UpdateFolderAssetsCommand(BaseCommand):
|
||||
"""Set a folder's asset membership.
|
||||
|
||||
``assets`` is the full desired membership: listed assets are moved into the
|
||||
folder (from wherever they were), and any of the folder's current assets not
|
||||
in the list are moved back to the root. An empty list empties the folder.
|
||||
"""
|
||||
|
||||
def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
|
||||
self._id = folder_id_or_uuid
|
||||
self._assets = assets
|
||||
self._model: Folder | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
|
||||
def run(self) -> Folder:
|
||||
self.validate()
|
||||
assert self._model
|
||||
FolderDAO.set_assets(self._model, self._assets)
|
||||
return self._model
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = _validate_folder_assets(self._id, self._assets)
|
||||
|
||||
|
||||
class AddFolderAssetsCommand(BaseCommand):
|
||||
"""Add assets to a folder (upsert).
|
||||
|
||||
Assets already in another folder are moved. Assets already in this folder
|
||||
are left as-is. Unlike ``UpdateFolderAssetsCommand``, existing assets in
|
||||
the folder that are not listed are **not** removed.
|
||||
"""
|
||||
|
||||
def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
|
||||
self._id = folder_id_or_uuid
|
||||
self._assets = assets
|
||||
self._model: Folder | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
|
||||
def run(self) -> Folder:
|
||||
self.validate()
|
||||
assert self._model
|
||||
FolderDAO.assign_assets(self._model, self._assets)
|
||||
return self._model
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = _validate_folder_assets(self._id, self._assets)
|
||||
93
superset/commands/folder/create.py
Normal file
93
superset/commands/folder/create.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask import g
|
||||
from marshmallow import ValidationError
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.extensions import db
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
from superset.commands.folder.exceptions import (
|
||||
FolderCreateFailedError,
|
||||
FolderInvalidError,
|
||||
FolderNameUniquenessValidationError,
|
||||
FolderParentNotFoundValidationError,
|
||||
FolderParentTypeMismatchValidationError,
|
||||
FolderTypeValidationError,
|
||||
)
|
||||
from superset.folders.constants import FOLDER_TYPE_ASSETS
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.models import Folder
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO
|
||||
|
||||
|
||||
class CreateFolderCommand(BaseCommand):
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
self._parent: Folder | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=FolderCreateFailedError))
|
||||
def run(self) -> Folder:
|
||||
self.validate()
|
||||
folder = FolderDAO.create(
|
||||
attributes={
|
||||
"name": self._properties["name"],
|
||||
"description": self._properties.get("description"),
|
||||
"parent_id": self._parent.id if self._parent else None,
|
||||
"folder_type": self._properties["folder_type"],
|
||||
"is_private": self._properties.get("is_private", False),
|
||||
}
|
||||
)
|
||||
db.session.flush()
|
||||
if (
|
||||
hasattr(g, "user")
|
||||
and not g.user.is_anonymous
|
||||
and not security_manager.is_admin()
|
||||
):
|
||||
FolderDAO.add_subject(folder.id, g.user.id, "editor")
|
||||
if self._parent:
|
||||
FolderPermissionDAO.copy_permissions_to_subfolder(
|
||||
self._parent.id, folder.id
|
||||
)
|
||||
return folder
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions: list[ValidationError] = []
|
||||
name = self._properties["name"]
|
||||
folder_type = self._properties["folder_type"]
|
||||
|
||||
if folder_type not in FOLDER_TYPE_ASSETS:
|
||||
exceptions.append(FolderTypeValidationError(folder_type))
|
||||
|
||||
if parent_uuid := self._properties.get("parent_uuid"):
|
||||
self._parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
|
||||
if self._parent is None:
|
||||
exceptions.append(FolderParentNotFoundValidationError())
|
||||
elif self._parent.folder_type != folder_type:
|
||||
exceptions.append(FolderParentTypeMismatchValidationError())
|
||||
|
||||
parent_id = self._parent.id if self._parent else None
|
||||
if not FolderDAO.validate_name_uniqueness(name, parent_id, folder_type):
|
||||
exceptions.append(FolderNameUniquenessValidationError())
|
||||
|
||||
if exceptions:
|
||||
raise FolderInvalidError(exceptions=exceptions)
|
||||
65
superset/commands/folder/delete.py
Normal file
65
superset/commands/folder/delete.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
from superset.commands.folder.exceptions import (
|
||||
FolderDeleteFailedError,
|
||||
FolderForbiddenError,
|
||||
FolderNotFoundError,
|
||||
)
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.models import Folder
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO
|
||||
|
||||
|
||||
class DeleteFolderCommand(BaseCommand):
|
||||
def __init__(self, folder_id_or_uuid: str, archive_items: bool = False):
|
||||
self._id = folder_id_or_uuid
|
||||
self._archive_items = archive_items
|
||||
self._model: Folder | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=FolderDeleteFailedError))
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
assert self._model
|
||||
FolderDAO.delete_folder(self._model, self._archive_items)
|
||||
|
||||
def validate(self) -> None:
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
self._model = FolderDAO.find_by_id_or_uuid(self._id)
|
||||
if not self._model:
|
||||
raise FolderNotFoundError()
|
||||
|
||||
# "Only Me" folder cannot be deleted
|
||||
extra = json_utils.loads(self._model.extra) if self._model.extra else {}
|
||||
if extra.get("only_me"):
|
||||
raise FolderForbiddenError()
|
||||
|
||||
if not security_manager.is_admin():
|
||||
user_id = get_user_id()
|
||||
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
|
||||
user_id, self._model.id
|
||||
):
|
||||
raise FolderForbiddenError()
|
||||
125
superset/commands/folder/exceptions.py
Normal file
125
superset/commands/folder/exceptions.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
from flask_babel import lazy_gettext as _
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ForbiddenError,
|
||||
UpdateFailedError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
class FolderForbiddenError(ForbiddenError):
|
||||
message = _("You don't have permission to access this folder.")
|
||||
|
||||
|
||||
class FolderInvalidError(CommandInvalidError):
|
||||
message = _("Folder parameters are invalid.")
|
||||
|
||||
|
||||
class FolderNotFoundError(CommandException):
|
||||
status = 404
|
||||
message = _("Folder not found.")
|
||||
|
||||
|
||||
class FolderCreateFailedError(CreateFailedError):
|
||||
message = _("Folder could not be created.")
|
||||
|
||||
|
||||
class FolderUpdateFailedError(UpdateFailedError):
|
||||
message = _("Folder could not be updated.")
|
||||
|
||||
|
||||
class FolderDeleteFailedError(DeleteFailedError):
|
||||
message = _("Folder could not be deleted.")
|
||||
|
||||
|
||||
class FolderNameUniquenessValidationError(ValidationError):
|
||||
"""A sibling folder of the same type already uses this name."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("A folder with this name already exists in this location")],
|
||||
field_name="name",
|
||||
)
|
||||
|
||||
|
||||
class FolderParentNotFoundValidationError(ValidationError):
|
||||
"""The referenced parent folder does not exist."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__([_("Parent folder not found")], field_name="parent_uuid")
|
||||
|
||||
|
||||
class FolderParentTypeMismatchValidationError(ValidationError):
|
||||
"""The parent folder belongs to a different folder type."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("Parent folder belongs to a different folder type")],
|
||||
field_name="parent_uuid",
|
||||
)
|
||||
|
||||
|
||||
class FolderCycleValidationError(ValidationError):
|
||||
"""A move would place a folder inside itself or a descendant."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("Cannot move a folder into itself or one of its descendants")],
|
||||
field_name="parent_uuid",
|
||||
)
|
||||
|
||||
|
||||
class FolderTypeValidationError(ValidationError):
|
||||
"""The folder_type is not a recognised namespace."""
|
||||
|
||||
def __init__(self, folder_type: str) -> None:
|
||||
super().__init__(
|
||||
[_("Unknown folder type: '%(type)s'", type=folder_type)],
|
||||
field_name="folder_type",
|
||||
)
|
||||
|
||||
|
||||
class FolderAssetNotFoundValidationError(ValidationError):
|
||||
"""A referenced asset does not exist."""
|
||||
|
||||
def __init__(self, asset_type: str, asset_id: int) -> None:
|
||||
super().__init__(
|
||||
[_("%(type)s %(id)s not found", type=asset_type, id=asset_id)],
|
||||
field_name="assets",
|
||||
)
|
||||
|
||||
|
||||
class FolderAssetTypeValidationError(ValidationError):
|
||||
"""An asset kind cannot be placed in a folder of this type."""
|
||||
|
||||
def __init__(self, asset_type: str, folder_type: str) -> None:
|
||||
super().__init__(
|
||||
[
|
||||
_(
|
||||
"Assets of type '%(type)s' cannot be added to a "
|
||||
"'%(folder_type)s' folder",
|
||||
type=asset_type,
|
||||
folder_type=folder_type,
|
||||
)
|
||||
],
|
||||
field_name="assets",
|
||||
)
|
||||
151
superset/commands/folder/update.py
Normal file
151
superset/commands/folder/update.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from marshmallow import ValidationError
|
||||
from superset import db, security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
from superset.commands.folder.exceptions import (
|
||||
FolderCycleValidationError,
|
||||
FolderForbiddenError,
|
||||
FolderInvalidError,
|
||||
FolderNameUniquenessValidationError,
|
||||
FolderNotFoundError,
|
||||
FolderParentNotFoundValidationError,
|
||||
FolderParentTypeMismatchValidationError,
|
||||
FolderUpdateFailedError,
|
||||
)
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.models import Folder
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO
|
||||
|
||||
|
||||
class UpdateFolderCommand(BaseCommand):
|
||||
def __init__(self, folder_id_or_uuid: str, data: dict[str, Any]):
|
||||
self._id = folder_id_or_uuid
|
||||
self._properties = data.copy()
|
||||
self._model: Folder | None = None
|
||||
self._parent_changed = False
|
||||
self._new_parent_id: int | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
|
||||
def run(self) -> Folder:
|
||||
self.validate()
|
||||
assert self._model
|
||||
|
||||
attributes: dict[str, Any] = {}
|
||||
if "name" in self._properties:
|
||||
attributes["name"] = self._properties["name"]
|
||||
if "description" in self._properties:
|
||||
attributes["description"] = self._properties["description"]
|
||||
if "is_private" in self._properties:
|
||||
attributes["is_private"] = self._properties["is_private"]
|
||||
if self._parent_changed:
|
||||
attributes["parent_id"] = self._new_parent_id
|
||||
|
||||
folder = FolderDAO.update(self._model, attributes)
|
||||
|
||||
# When moving to a new parent: copy permissions and remove root pins
|
||||
if self._parent_changed and self._new_parent_id is not None:
|
||||
FolderPermissionDAO.copy_permissions_to_subfolder(
|
||||
self._new_parent_id, folder.id
|
||||
)
|
||||
from superset.folders.models import FolderPin
|
||||
|
||||
db.session.query(FolderPin).filter(
|
||||
FolderPin.folder_id == folder.id,
|
||||
).delete()
|
||||
|
||||
# Sync permissions from parent when requested
|
||||
if self._properties.get("sync_permissions") and folder.parent_id:
|
||||
FolderPermissionDAO.copy_permissions_to_subfolder(
|
||||
folder.parent_id, folder.id
|
||||
)
|
||||
FolderPermissionDAO.push_down_permissions(folder.id)
|
||||
|
||||
return folder
|
||||
|
||||
def _validate_parent(self, exceptions: list[ValidationError]) -> int | None:
|
||||
"""Validate and resolve parent_uuid, returning the new parent_id."""
|
||||
new_parent_id = self._model.parent_id # type: ignore[union-attr]
|
||||
if "parent_uuid" not in self._properties:
|
||||
return new_parent_id
|
||||
|
||||
self._parent_changed = True
|
||||
parent: Folder | None = None
|
||||
if parent_uuid := self._properties["parent_uuid"]:
|
||||
parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
|
||||
if parent is None:
|
||||
exceptions.append(FolderParentNotFoundValidationError())
|
||||
elif parent.folder_type != self._model.folder_type: # type: ignore[union-attr]
|
||||
exceptions.append(FolderParentTypeMismatchValidationError())
|
||||
elif FolderDAO.is_descendant(parent, self._model): # type: ignore[arg-type]
|
||||
exceptions.append(FolderCycleValidationError())
|
||||
elif not security_manager.is_admin():
|
||||
user_id = get_user_id()
|
||||
if not user_id or not FolderPermissionDAO.user_is_folder_editor(user_id, parent.id):
|
||||
raise FolderForbiddenError()
|
||||
new_parent_id = parent.id if parent else None
|
||||
self._new_parent_id = new_parent_id
|
||||
return new_parent_id
|
||||
|
||||
def validate(self) -> None:
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
self._model = FolderDAO.find_by_id_or_uuid(self._id)
|
||||
if not self._model:
|
||||
raise FolderNotFoundError()
|
||||
|
||||
if not security_manager.is_admin():
|
||||
user_id = get_user_id()
|
||||
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
|
||||
user_id, self._model.id
|
||||
):
|
||||
raise FolderForbiddenError()
|
||||
|
||||
# "Only Me" folder: reject name/parent changes (sync_permissions is allowed)
|
||||
extra = json_utils.loads(self._model.extra) if self._model.extra else {}
|
||||
if extra.get("only_me"):
|
||||
if "name" in self._properties or "parent_uuid" in self._properties:
|
||||
raise FolderForbiddenError()
|
||||
|
||||
# Private folders cannot be moved into other folders
|
||||
if self._model.is_private and "parent_uuid" in self._properties:
|
||||
raise FolderForbiddenError()
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
new_parent_id = self._validate_parent(exceptions)
|
||||
|
||||
new_name = self._properties.get("name", self._model.name)
|
||||
if new_name != self._model.name or new_parent_id != self._model.parent_id:
|
||||
resolved = FolderDAO.resolve_name_conflict(
|
||||
new_name,
|
||||
new_parent_id,
|
||||
self._model.folder_type,
|
||||
exclude_id=self._model.id,
|
||||
)
|
||||
if resolved != new_name:
|
||||
self._properties["name"] = resolved
|
||||
|
||||
if exceptions:
|
||||
raise FolderInvalidError(exceptions=exceptions)
|
||||
@@ -2633,6 +2633,31 @@ class ExtraDynamicQueryFilters(TypedDict, total=False):
|
||||
|
||||
EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extension hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional filters applied to chart/dashboard list queries. Each callable
|
||||
# receives (query, model) and returns a modified query.
|
||||
# Example: {"chart": my_chart_filter, "dashboard": my_dashboard_filter}
|
||||
EXTRA_ACCESS_QUERY_FILTERS: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
# Callable that can bypass ``raise_for_access`` for specific assets. Receives
|
||||
# the keyword arguments passed to ``raise_for_access`` and returns ``True`` to
|
||||
# skip the default access check.
|
||||
EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
|
||||
|
||||
# Resolve additional owners for an asset beyond the ``owners`` relationship.
|
||||
# Receives a model instance and returns a list of user objects. Used both for
|
||||
# API serialization (extra_owners) and ownership checks (is_owner). When
|
||||
# configured, the auto-add of the current user to ``owners`` on create is
|
||||
# skipped — the resolver is expected to handle access independently.
|
||||
EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
|
||||
|
||||
# Called after a chart or dashboard is created. Receives the model instance
|
||||
# and the asset type string ("chart" or "dashboard"). Runs inside the same
|
||||
# transaction — if it raises, the create is rolled back.
|
||||
AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
|
||||
|
||||
|
||||
# Extra access query filters inject additional OR conditions into
|
||||
# ChartFilter and DashboardAccessFilter, enabling external systems
|
||||
@@ -2653,6 +2678,10 @@ EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
|
||||
EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
|
||||
# Post-create hook for charts/dashboards. Receives (model, asset_type).
|
||||
AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
|
||||
# When True, folder viewers/editors can access all charts and datasources on a
|
||||
# dashboard that is in their folder, even if those charts are not individually
|
||||
# in any folder. Disabled by default.
|
||||
FOLDER_DASHBOARD_TRANSITIVE_ACCESS: bool = False
|
||||
|
||||
|
||||
# The migrations that add catalog permissions might take a considerably long time
|
||||
|
||||
809
superset/daos/folder.py
Normal file
809
superset/daos/folder.py
Normal file
@@ -0,0 +1,809 @@
|
||||
# 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.
|
||||
"""Data access for folders and the assets they contain.
|
||||
|
||||
Validation and orchestration live in ``preset.folders.commands``; this DAO is
|
||||
limited to queries and persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from sqlalchemy import and_, case, func, literal, or_, select, union_all
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.extensions import db
|
||||
from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
|
||||
from superset.folders.models import (
|
||||
Folder,
|
||||
folder_editors,
|
||||
folder_viewers,
|
||||
FolderObject,
|
||||
FolderPin,
|
||||
)
|
||||
|
||||
# A resolved asset: its type label paired with the underlying model instance.
|
||||
ResolvedAsset = tuple[str, Any]
|
||||
|
||||
|
||||
class FolderDAO(BaseDAO[Folder]):
|
||||
"""Queries and persistence for folders and their asset links."""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lookups
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def get_by_uuid(cls, folder_uuid: str) -> Folder | None:
|
||||
"""Find a folder by UUID (or numeric id)."""
|
||||
return cls.find_by_id_or_uuid(folder_uuid)
|
||||
|
||||
@classmethod
|
||||
def get_folders(cls, folder_type: str | None = None) -> list[Folder]:
|
||||
"""List folders, optionally filtered by ``folder_type``."""
|
||||
query = db.session.query(Folder)
|
||||
if folder_type:
|
||||
query = query.filter(Folder.folder_type == folder_type)
|
||||
return query.order_by(Folder.changed_on.desc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_roots(cls, folder_type: str) -> list[Folder]:
|
||||
"""Top-level folders (no parent) for a folder type."""
|
||||
return (
|
||||
db.session.query(Folder)
|
||||
.filter(Folder.parent_id.is_(None), Folder.folder_type == folder_type)
|
||||
.order_by(Folder.changed_on.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_children(cls, folder: Folder) -> list[Folder]:
|
||||
"""Direct subfolders of ``folder``."""
|
||||
return (
|
||||
db.session.query(Folder)
|
||||
.filter(Folder.parent_id == folder.id)
|
||||
.order_by(Folder.changed_on.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Asset queries
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def get_folder_assets(cls, folder: Folder) -> list[ResolvedAsset]:
|
||||
"""Resolve the assets linked directly to ``folder`` into model objects."""
|
||||
ids_by_type: dict[str, list[int]] = {name: [] for name in ASSET_TYPE_CONFIGS}
|
||||
for link in folder.objects:
|
||||
for name, config in ASSET_TYPE_CONFIGS.items():
|
||||
asset_id = getattr(link, config.fk_column)
|
||||
if asset_id is not None:
|
||||
ids_by_type[name].append(asset_id)
|
||||
return cls._load_assets(ids_by_type)
|
||||
|
||||
@classmethod
|
||||
def get_contents( # noqa: C901
|
||||
cls,
|
||||
folder: Folder | None,
|
||||
folder_type: str,
|
||||
*,
|
||||
search: str | None = None,
|
||||
types: list[str] | None = None,
|
||||
viz_types: list[str] | None = None,
|
||||
datasets: list[int] | None = None,
|
||||
tags: list[int] | None = None,
|
||||
owners: list[int] | None = None,
|
||||
modified_start: datetime | None = None,
|
||||
modified_end: datetime | None = None,
|
||||
page: int = 0,
|
||||
page_size: int = 25,
|
||||
sort_column: str = "changed_on",
|
||||
sort_order: str = "desc",
|
||||
) -> tuple[list[ResolvedAsset], int]:
|
||||
"""Paginated, filtered contents of a folder (or the root).
|
||||
|
||||
Returns ``(rows, total)`` where each row is ``(kind, model_instance)`` with
|
||||
``kind`` in ``{"folder", <asset types>}``. Pagination happens in the DB via a
|
||||
``UNION ALL`` over the included sources ordered by (folders first, then most
|
||||
recently changed), so callers get exactly one page plus the total count.
|
||||
"""
|
||||
from superset import security_manager
|
||||
from superset.connectors.sqla.models import Database as ConnDatabase, SqlaTable
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils.core import get_user_id
|
||||
|
||||
folder_id = folder.id if folder else None
|
||||
requested = set(types) if types else None
|
||||
asset_only_filter = bool(viz_types or datasets or owners)
|
||||
is_admin = security_manager.is_admin()
|
||||
user_id = get_user_id()
|
||||
|
||||
def included(kind: str) -> bool:
|
||||
return requested is None or kind in requested
|
||||
|
||||
selects = []
|
||||
|
||||
# --- Folders (subfolders / top-level) ---
|
||||
if included("folder") and not asset_only_filter:
|
||||
fq = select(
|
||||
literal(0).label("kind_order"),
|
||||
literal("folder").label("item_type"),
|
||||
Folder.id.label("item_id"),
|
||||
Folder.changed_on.label("changed_on"),
|
||||
Folder.name.label("sort_name"),
|
||||
literal("").label("sort_datasource"),
|
||||
).where(Folder.folder_type == folder_type)
|
||||
fq = fq.where(
|
||||
Folder.parent_id.is_(None)
|
||||
if folder_id is None
|
||||
else Folder.parent_id == folder_id
|
||||
)
|
||||
if search:
|
||||
fq = fq.where(Folder.name.ilike(f"%{search}%"))
|
||||
if modified_start:
|
||||
fq = fq.where(Folder.changed_on >= modified_start)
|
||||
if modified_end:
|
||||
fq = fq.where(Folder.changed_on <= modified_end)
|
||||
# Hide other users' private folders from everyone (including admins)
|
||||
if user_id:
|
||||
own_private_ids = (
|
||||
select(folder_editors.c.folder_id).where(
|
||||
folder_editors.c.user_id == user_id
|
||||
)
|
||||
)
|
||||
fq = fq.where(
|
||||
or_(
|
||||
Folder.is_private.is_(False),
|
||||
Folder.id.in_(own_private_ids),
|
||||
)
|
||||
)
|
||||
# Access filter: non-admins only see folders they are a member of
|
||||
if not is_admin and user_id:
|
||||
accessible_folder_ids = (
|
||||
select(folder_viewers.c.folder_id)
|
||||
.where(folder_viewers.c.user_id == user_id)
|
||||
.union(
|
||||
select(folder_editors.c.folder_id).where(
|
||||
folder_editors.c.user_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
fq = fq.where(Folder.id.in_(accessible_folder_ids))
|
||||
selects.append(fq)
|
||||
|
||||
# --- Assets (charts / dashboards for this folder type) ---
|
||||
title_attrs = {
|
||||
"chart": Slice.slice_name,
|
||||
"dashboard": Dashboard.dashboard_title,
|
||||
}
|
||||
for name in asset_types_for_folder_type(folder_type):
|
||||
if not included(name):
|
||||
continue
|
||||
# viz/dataset filters only make sense for charts.
|
||||
if (viz_types or datasets) and name != "chart":
|
||||
continue
|
||||
model = ASSET_TYPE_CONFIGS[name].model
|
||||
fk_col = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
|
||||
if name == "chart":
|
||||
aq = (
|
||||
select(
|
||||
literal(1).label("kind_order"),
|
||||
literal(name).label("item_type"),
|
||||
model.id.label("item_id"),
|
||||
model.changed_on.label("changed_on"),
|
||||
title_attrs[name].label("sort_name"),
|
||||
func.coalesce(
|
||||
func.concat(
|
||||
func.coalesce(SqlaTable.schema, ""),
|
||||
case(
|
||||
(SqlaTable.schema.isnot(None), "."),
|
||||
else_="",
|
||||
),
|
||||
func.coalesce(SqlaTable.table_name, ""),
|
||||
),
|
||||
"",
|
||||
).label("sort_datasource"),
|
||||
)
|
||||
.outerjoin(SqlaTable, Slice.datasource_id == SqlaTable.id)
|
||||
)
|
||||
else:
|
||||
aq = select(
|
||||
literal(1).label("kind_order"),
|
||||
literal(name).label("item_type"),
|
||||
model.id.label("item_id"),
|
||||
model.changed_on.label("changed_on"),
|
||||
title_attrs[name].label("sort_name"),
|
||||
literal("").label("sort_datasource"),
|
||||
)
|
||||
if folder_id is None:
|
||||
used = select(fk_col).where(fk_col.isnot(None))
|
||||
aq = aq.where(model.id.notin_(used))
|
||||
else:
|
||||
in_folder = select(fk_col).where(
|
||||
FolderObject.folder_id == folder_id, fk_col.isnot(None)
|
||||
)
|
||||
aq = aq.where(model.id.in_(in_folder))
|
||||
if search:
|
||||
aq = aq.where(title_attrs[name].ilike(f"%{search}%"))
|
||||
if modified_start:
|
||||
aq = aq.where(model.changed_on >= modified_start)
|
||||
if modified_end:
|
||||
aq = aq.where(model.changed_on <= modified_end)
|
||||
if owners:
|
||||
aq = aq.where(model.owners.any(cls._user_model().id.in_(owners)))
|
||||
if name == "chart":
|
||||
if viz_types:
|
||||
aq = aq.where(Slice.viz_type.in_(viz_types))
|
||||
if datasets:
|
||||
aq = aq.where(Slice.datasource_id.in_(datasets))
|
||||
if tags:
|
||||
from superset.tags.models import ObjectType, TaggedObject
|
||||
|
||||
object_type = (
|
||||
ObjectType.chart if name == "chart" else ObjectType.dashboard
|
||||
)
|
||||
tagged = select(TaggedObject.object_id).where(
|
||||
TaggedObject.object_type == object_type,
|
||||
TaggedObject.tag_id.in_(tags),
|
||||
)
|
||||
aq = aq.where(model.id.in_(tagged))
|
||||
|
||||
# Access filter: non-admins only see assets they can access
|
||||
if not is_admin and user_id:
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
|
||||
access_conditions = []
|
||||
|
||||
# 1. User owns the asset
|
||||
access_conditions.append(
|
||||
model.owners.any(cls._user_model().id == user_id)
|
||||
)
|
||||
|
||||
# 2. User has folder membership for the asset
|
||||
user_folder_ids = (
|
||||
select(folder_viewers.c.folder_id)
|
||||
.where(folder_viewers.c.user_id == user_id)
|
||||
.union(
|
||||
select(folder_editors.c.folder_id).where(
|
||||
folder_editors.c.user_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
folder_accessible = (
|
||||
select(fk_col)
|
||||
.select_from(FolderObject)
|
||||
.where(
|
||||
fk_col.isnot(None),
|
||||
FolderObject.folder_id.in_(user_folder_ids),
|
||||
)
|
||||
)
|
||||
access_conditions.append(model.id.in_(folder_accessible))
|
||||
|
||||
# 3. Chart: user has datasource access
|
||||
can_access_all = security_manager.can_access_all_datasources()
|
||||
if name == "chart" and not can_access_all:
|
||||
ds_accessible = (
|
||||
select(Slice.id)
|
||||
.join(
|
||||
SqlaTable,
|
||||
Slice.datasource_id == SqlaTable.id,
|
||||
)
|
||||
.join(
|
||||
ConnDatabase,
|
||||
SqlaTable.database_id == ConnDatabase.id,
|
||||
)
|
||||
.where(get_dataset_access_filters(Slice))
|
||||
)
|
||||
access_conditions.append(model.id.in_(ds_accessible))
|
||||
|
||||
# 4. Dashboard: published + user has datasource access
|
||||
if name == "dashboard":
|
||||
if can_access_all:
|
||||
access_conditions.append(Dashboard.published.is_(True))
|
||||
else:
|
||||
published_accessible = (
|
||||
select(Dashboard.id)
|
||||
.join(Dashboard.slices, isouter=True)
|
||||
.join(
|
||||
SqlaTable,
|
||||
Slice.datasource_id == SqlaTable.id,
|
||||
)
|
||||
.join(
|
||||
ConnDatabase,
|
||||
SqlaTable.database_id == ConnDatabase.id,
|
||||
)
|
||||
.where(
|
||||
Dashboard.published.is_(True),
|
||||
get_dataset_access_filters(Slice, can_access_all),
|
||||
)
|
||||
)
|
||||
access_conditions.append(model.id.in_(published_accessible))
|
||||
|
||||
aq = aq.where(or_(*access_conditions))
|
||||
selects.append(aq)
|
||||
|
||||
if not selects:
|
||||
return [], 0
|
||||
|
||||
unioned = union_all(*selects).subquery()
|
||||
total = (
|
||||
db.session.execute(select(func.count()).select_from(unioned)).scalar() or 0
|
||||
)
|
||||
sort_map = {
|
||||
"name": unioned.c.sort_name,
|
||||
"type": unioned.c.item_type,
|
||||
"changed_on": unioned.c.changed_on,
|
||||
"datasource": unioned.c.sort_datasource,
|
||||
}
|
||||
sort_col = sort_map.get(sort_column, unioned.c.changed_on)
|
||||
order = sort_col.asc() if sort_order == "asc" else sort_col.desc()
|
||||
|
||||
# Sort priority at root: Only Me (0) → pinned (1-3) → unpinned (4)
|
||||
pin_order = (literal(4) + literal(0)).label("pin_order")
|
||||
if folder_id is None and user_id:
|
||||
only_me_order = (
|
||||
select(literal(0) + literal(0))
|
||||
.where(
|
||||
unioned.c.item_type == "folder",
|
||||
Folder.id == unioned.c.item_id,
|
||||
Folder.is_private.is_(True),
|
||||
)
|
||||
.correlate(unioned)
|
||||
.scalar_subquery()
|
||||
)
|
||||
pin_position = (
|
||||
select(FolderPin.position)
|
||||
.where(
|
||||
FolderPin.user_id == user_id,
|
||||
or_(
|
||||
and_(
|
||||
unioned.c.item_type == "folder",
|
||||
FolderPin.folder_id == unioned.c.item_id,
|
||||
),
|
||||
and_(
|
||||
unioned.c.item_type == "dashboard",
|
||||
FolderPin.dashboard_id == unioned.c.item_id,
|
||||
),
|
||||
and_(
|
||||
unioned.c.item_type == "chart",
|
||||
FolderPin.chart_id == unioned.c.item_id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(unioned)
|
||||
.scalar_subquery()
|
||||
)
|
||||
pin_order = func.coalesce(
|
||||
only_me_order, pin_position, literal(4) + literal(0)
|
||||
).label("pin_order")
|
||||
|
||||
page_rows = db.session.execute(
|
||||
select(unioned.c.item_type, unioned.c.item_id)
|
||||
.order_by(pin_order, order)
|
||||
.limit(page_size)
|
||||
.offset(page * page_size)
|
||||
).all()
|
||||
|
||||
# Hydrate the page's ids into model instances, preserving order.
|
||||
ids_by_type: dict[str, list[int]] = defaultdict(list)
|
||||
for item_type, item_id in page_rows:
|
||||
ids_by_type[item_type].append(item_id)
|
||||
objects: dict[tuple[str, int], Any] = {}
|
||||
if ids_by_type.get("folder"):
|
||||
for obj in (
|
||||
db.session.query(Folder)
|
||||
.options(
|
||||
joinedload(Folder.parent),
|
||||
joinedload(Folder.created_by),
|
||||
joinedload(Folder.changed_by),
|
||||
)
|
||||
.filter(Folder.id.in_(ids_by_type["folder"]))
|
||||
.all()
|
||||
):
|
||||
objects[("folder", obj.id)] = obj
|
||||
for name in asset_types_for_folder_type(folder_type):
|
||||
if not ids_by_type.get(name):
|
||||
continue
|
||||
model = ASSET_TYPE_CONFIGS[name].model
|
||||
for obj in (
|
||||
db.session.query(model).filter(model.id.in_(ids_by_type[name])).all()
|
||||
):
|
||||
objects[(name, obj.id)] = obj
|
||||
|
||||
rows: list[ResolvedAsset] = [
|
||||
(item_type, objects[(item_type, item_id)])
|
||||
for item_type, item_id in page_rows
|
||||
if (item_type, item_id) in objects
|
||||
]
|
||||
return rows, total
|
||||
|
||||
@staticmethod
|
||||
def _user_model() -> Any:
|
||||
from superset import security_manager
|
||||
|
||||
return security_manager.user_model
|
||||
|
||||
@classmethod
|
||||
def _load_assets(cls, ids_by_type: dict[str, list[int]]) -> list[ResolvedAsset]:
|
||||
resolved: list[ResolvedAsset] = []
|
||||
for name, ids in ids_by_type.items():
|
||||
if not ids:
|
||||
continue
|
||||
model = ASSET_TYPE_CONFIGS[name].model
|
||||
rows = db.session.query(model).filter(model.id.in_(ids)).all()
|
||||
resolved.extend((name, row) for row in rows)
|
||||
return resolved
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Persistence
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
|
||||
"""Delete a folder, re-parenting children to its parent.
|
||||
|
||||
When ``archive_items`` is False (default), assets linked to the folder
|
||||
become unfoldered. When True, the assets themselves are also deleted.
|
||||
"""
|
||||
for child in list(folder.children):
|
||||
child.parent_id = folder.parent_id
|
||||
child.name = cls.resolve_name_conflict(
|
||||
child.name, folder.parent_id, folder.folder_type, exclude_id=child.id
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
if archive_items:
|
||||
for link in list(folder.objects):
|
||||
for _name, config in ASSET_TYPE_CONFIGS.items():
|
||||
asset_id = getattr(link, config.fk_column)
|
||||
if asset_id is not None:
|
||||
asset = db.session.get(config.model, asset_id)
|
||||
if asset:
|
||||
db.session.delete(asset)
|
||||
break
|
||||
|
||||
db.session.delete(folder)
|
||||
|
||||
@classmethod
|
||||
def assign_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
|
||||
"""Assign/move assets into ``folder`` (upsert).
|
||||
|
||||
An asset can live in only one folder, so an existing link is moved rather
|
||||
than duplicated. Callers are expected to have validated the assets.
|
||||
"""
|
||||
for asset in assets:
|
||||
config = ASSET_TYPE_CONFIGS[asset["type"]]
|
||||
link_column = getattr(FolderObject, config.fk_column)
|
||||
link = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(link_column == asset["id"])
|
||||
.one_or_none()
|
||||
)
|
||||
if link:
|
||||
link.folder_id = folder.id
|
||||
else:
|
||||
link = FolderObject(folder_id=folder.id, created_on=datetime.utcnow())
|
||||
setattr(link, config.fk_column, asset["id"])
|
||||
db.session.add(link)
|
||||
|
||||
# Remove any pins for this asset since it's no longer at root
|
||||
pin_col = {
|
||||
"folder": FolderPin.folder_id,
|
||||
"dashboard": FolderPin.dashboard_id,
|
||||
"chart": FolderPin.chart_id,
|
||||
}[asset["type"]]
|
||||
db.session.query(FolderPin).filter(
|
||||
pin_col == asset["id"],
|
||||
).delete()
|
||||
|
||||
@classmethod
|
||||
def set_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
|
||||
"""Set ``folder``'s membership to exactly ``assets``.
|
||||
|
||||
Listed assets are moved into the folder; the folder's current assets that
|
||||
are not listed have their link removed (back to the root). An empty list
|
||||
empties the folder. Callers are expected to have validated the assets.
|
||||
"""
|
||||
desired = {(asset["type"], asset["id"]) for asset in assets}
|
||||
for link in list(folder.objects):
|
||||
for name, config in ASSET_TYPE_CONFIGS.items():
|
||||
asset_id = getattr(link, config.fk_column)
|
||||
if asset_id is not None and (name, asset_id) not in desired:
|
||||
db.session.delete(link)
|
||||
break
|
||||
db.session.flush()
|
||||
cls.assign_assets(folder, assets)
|
||||
|
||||
@classmethod
|
||||
def remove_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
|
||||
"""Remove specific assets from ``folder`` (back to root)."""
|
||||
for asset in assets:
|
||||
config = ASSET_TYPE_CONFIGS[asset["type"]]
|
||||
link_column = getattr(FolderObject, config.fk_column)
|
||||
link = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(
|
||||
FolderObject.folder_id == folder.id,
|
||||
link_column == asset["id"],
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if link:
|
||||
db.session.delete(link)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validation helpers (used by commands)
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def validate_name_uniqueness(
|
||||
cls,
|
||||
name: str,
|
||||
parent_id: int | None,
|
||||
folder_type: str,
|
||||
exclude_id: int | None = None,
|
||||
) -> bool:
|
||||
"""True if no sibling of the same type already uses ``name``."""
|
||||
query = db.session.query(Folder.id).filter(
|
||||
Folder.parent_id.is_(None)
|
||||
if parent_id is None
|
||||
else Folder.parent_id == parent_id,
|
||||
Folder.name == name,
|
||||
Folder.folder_type == folder_type,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.filter(Folder.id != exclude_id)
|
||||
return query.first() is None
|
||||
|
||||
@classmethod
|
||||
def resolve_name_conflict(
|
||||
cls,
|
||||
name: str,
|
||||
parent_id: int | None,
|
||||
folder_type: str,
|
||||
exclude_id: int | None = None,
|
||||
) -> str:
|
||||
"""Return a unique name by appending a numeric suffix if needed."""
|
||||
if cls.validate_name_uniqueness(name, parent_id, folder_type, exclude_id):
|
||||
return name
|
||||
for counter in range(1, 1001):
|
||||
candidate = f"{name} ({counter})"
|
||||
if cls.validate_name_uniqueness(
|
||||
candidate, parent_id, folder_type, exclude_id
|
||||
):
|
||||
return candidate
|
||||
raise ValueError(f"Cannot resolve name conflict for '{name}'")
|
||||
|
||||
@classmethod
|
||||
def is_descendant(cls, candidate: Folder, ancestor: Folder) -> bool:
|
||||
"""True if ``candidate`` is ``ancestor`` itself or below it in the tree."""
|
||||
node: Folder | None = candidate
|
||||
while node is not None:
|
||||
if node.id == ancestor.id:
|
||||
return True
|
||||
node = node.parent
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def asset_exists(cls, asset_type: str, asset_id: int) -> bool:
|
||||
"""True if the referenced asset exists."""
|
||||
model = ASSET_TYPE_CONFIGS[asset_type].model
|
||||
return (
|
||||
db.session.query(model.id).filter(model.id == asset_id).first() is not None
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Private / "Only Me" folders
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def get_or_create_only_me_folder(cls, user_id: int) -> Folder:
|
||||
"""Return the user's 'Only Me' folder, creating it if it doesn't exist."""
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
folder = (
|
||||
db.session.query(Folder)
|
||||
.join(folder_editors, folder_editors.c.folder_id == Folder.id)
|
||||
.filter(
|
||||
folder_editors.c.user_id == user_id,
|
||||
Folder.is_private.is_(True),
|
||||
Folder.parent_id.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for f in folder:
|
||||
extra = json_utils.loads(f.extra) if f.extra else {}
|
||||
if extra.get("only_me"):
|
||||
return f
|
||||
|
||||
new_folder = cls.create(
|
||||
attributes={
|
||||
"name": "Only Me",
|
||||
"is_private": True,
|
||||
"parent_id": None,
|
||||
"folder_type": "analytics",
|
||||
"extra": json_utils.dumps({"only_me": True, "inherits_permissions": False}),
|
||||
}
|
||||
)
|
||||
db.session.flush()
|
||||
db.session.execute(
|
||||
folder_editors.insert().values(folder_id=new_folder.id, user_id=user_id)
|
||||
)
|
||||
db.session.flush()
|
||||
return new_folder
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pins
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def get_pins(cls, user_id: int) -> list[FolderPin]:
|
||||
return (
|
||||
db.session.query(FolderPin)
|
||||
.filter(FolderPin.user_id == user_id)
|
||||
.order_by(FolderPin.position)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_pin(
|
||||
cls,
|
||||
user_id: int,
|
||||
object_id: int,
|
||||
object_type: str,
|
||||
position: int,
|
||||
) -> FolderPin:
|
||||
pin = FolderPin(
|
||||
user_id=user_id,
|
||||
folder_id=object_id if object_type == "folder" else None,
|
||||
dashboard_id=object_id if object_type == "dashboard" else None,
|
||||
chart_id=object_id if object_type == "chart" else None,
|
||||
position=position,
|
||||
created_on=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(pin)
|
||||
db.session.flush()
|
||||
return pin
|
||||
|
||||
@classmethod
|
||||
def delete_pin(cls, pin_id: int, user_id: int) -> bool:
|
||||
pin = (
|
||||
db.session.query(FolderPin)
|
||||
.filter(FolderPin.id == pin_id, FolderPin.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not pin:
|
||||
return False
|
||||
db.session.delete(pin)
|
||||
db.session.flush()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Subjects (permissions)
|
||||
# ------------------------------------------------------------------ #
|
||||
@classmethod
|
||||
def get_subjects(cls, folder_id: int) -> list[dict[str, Any]]:
|
||||
subjects: list[dict[str, Any]] = []
|
||||
|
||||
editors = db.session.execute(
|
||||
folder_editors.select().where(folder_editors.c.folder_id == folder_id)
|
||||
).fetchall()
|
||||
viewers = db.session.execute(
|
||||
folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
|
||||
).fetchall()
|
||||
|
||||
all_user_ids = [r.user_id for r in editors] + [r.user_id for r in viewers]
|
||||
users_by_id = (
|
||||
{
|
||||
u.id: u
|
||||
for u in db.session.query(User).filter(User.id.in_(all_user_ids)).all()
|
||||
}
|
||||
if all_user_ids
|
||||
else {}
|
||||
)
|
||||
|
||||
for row in editors:
|
||||
user = users_by_id.get(row.user_id)
|
||||
subjects.append(
|
||||
{
|
||||
"user_id": row.user_id,
|
||||
"permission": "editor",
|
||||
"email": user.email if user else None,
|
||||
}
|
||||
)
|
||||
|
||||
for row in viewers:
|
||||
user = users_by_id.get(row.user_id)
|
||||
subjects.append(
|
||||
{
|
||||
"user_id": row.user_id,
|
||||
"permission": "viewer",
|
||||
"email": user.email if user else None,
|
||||
}
|
||||
)
|
||||
|
||||
return subjects
|
||||
|
||||
@classmethod
|
||||
def add_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
if not db.session.get(User, user_id):
|
||||
raise ValueError(f"User {user_id} does not exist")
|
||||
|
||||
existing = db.session.execute(
|
||||
folder_editors.select().where(
|
||||
and_(folder_editors.c.folder_id == folder_id, folder_editors.c.user_id == user_id)
|
||||
)
|
||||
).first() or db.session.execute(
|
||||
folder_viewers.select().where(
|
||||
and_(folder_viewers.c.folder_id == folder_id, folder_viewers.c.user_id == user_id)
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise ValueError(f"User {user_id} is already a member of this folder")
|
||||
|
||||
if permission == "editor":
|
||||
db.session.execute(
|
||||
folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
|
||||
)
|
||||
else:
|
||||
db.session.execute(
|
||||
folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@classmethod
|
||||
def update_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
|
||||
# Remove from both, then add to the correct one
|
||||
db.session.execute(
|
||||
folder_editors.delete().where(
|
||||
and_(
|
||||
folder_editors.c.folder_id == folder_id,
|
||||
folder_editors.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.session.execute(
|
||||
folder_viewers.delete().where(
|
||||
and_(
|
||||
folder_viewers.c.folder_id == folder_id,
|
||||
folder_viewers.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
cls.add_subject(folder_id, user_id, permission)
|
||||
|
||||
@classmethod
|
||||
def remove_subject(cls, folder_id: int, user_id: int) -> None:
|
||||
db.session.execute(
|
||||
folder_editors.delete().where(
|
||||
and_(
|
||||
folder_editors.c.folder_id == folder_id,
|
||||
folder_editors.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.session.execute(
|
||||
folder_viewers.delete().where(
|
||||
and_(
|
||||
folder_viewers.c.folder_id == folder_id,
|
||||
folder_viewers.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
470
superset/daos/folder_permissions.py
Normal file
470
superset/daos/folder_permissions.py
Normal file
@@ -0,0 +1,470 @@
|
||||
# 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.
|
||||
"""Data access for folder permission operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from superset import db
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
from superset.folders.models import (
|
||||
Folder,
|
||||
folder_editors,
|
||||
folder_viewers,
|
||||
FolderObject,
|
||||
)
|
||||
|
||||
|
||||
class FolderPermissionDAO:
|
||||
"""Queries and persistence for folder permissions (editors/viewers)."""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Subject queries
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def get_subjects(folder_id: int) -> list[dict[str, Any]]:
|
||||
"""Get all editors and viewers for a folder."""
|
||||
subjects: list[dict[str, Any]] = []
|
||||
|
||||
editors = db.session.execute(
|
||||
folder_editors.select().where(folder_editors.c.folder_id == folder_id)
|
||||
).fetchall()
|
||||
for row in editors:
|
||||
subjects.append({"user_id": row.user_id, "permission": "editor"})
|
||||
|
||||
viewers = db.session.execute(
|
||||
folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
|
||||
).fetchall()
|
||||
for row in viewers:
|
||||
subjects.append({"user_id": row.user_id, "permission": "viewer"})
|
||||
|
||||
return subjects
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Subject mutations
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def add_editor(folder_id: int, user_id: int) -> None:
|
||||
db.session.execute(
|
||||
folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def add_viewer(folder_id: int, user_id: int) -> None:
|
||||
db.session.execute(
|
||||
folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def remove_editor(folder_id: int, user_id: int) -> None:
|
||||
db.session.execute(
|
||||
folder_editors.delete().where(
|
||||
and_(
|
||||
folder_editors.c.folder_id == folder_id,
|
||||
folder_editors.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def remove_viewer(folder_id: int, user_id: int) -> None:
|
||||
db.session.execute(
|
||||
folder_viewers.delete().where(
|
||||
and_(
|
||||
folder_viewers.c.folder_id == folder_id,
|
||||
folder_viewers.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def update_subject_permission(
|
||||
folder_id: int, user_id: int, permission: str
|
||||
) -> None:
|
||||
"""Move a user between editors and viewers."""
|
||||
if permission not in ("editor", "viewer"):
|
||||
raise ValueError(
|
||||
f"Invalid permission: {permission!r}. Must be 'editor' or 'viewer'."
|
||||
)
|
||||
if permission == "editor":
|
||||
FolderPermissionDAO.remove_viewer(folder_id, user_id)
|
||||
FolderPermissionDAO.add_editor(folder_id, user_id)
|
||||
else:
|
||||
FolderPermissionDAO.remove_editor(folder_id, user_id)
|
||||
FolderPermissionDAO.add_viewer(folder_id, user_id)
|
||||
|
||||
@staticmethod
|
||||
def remove_subject(folder_id: int, user_id: int) -> None:
|
||||
"""Remove a user from both editors and viewers."""
|
||||
FolderPermissionDAO.remove_editor(folder_id, user_id)
|
||||
FolderPermissionDAO.remove_viewer(folder_id, user_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Permission inheritance
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def copy_permissions_to_subfolder(
|
||||
parent_folder_id: int, child_folder_id: int
|
||||
) -> None:
|
||||
"""Replace child folder permissions with parent's.
|
||||
|
||||
Clears existing editors/viewers on the child, then copies the parent's.
|
||||
Sets extra.inherits_permissions = true on the child.
|
||||
Skips private folders — their permissions are owner-only.
|
||||
"""
|
||||
child = db.session.query(Folder).get(child_folder_id)
|
||||
if child and child.is_private:
|
||||
return
|
||||
|
||||
# Clear child's existing permissions
|
||||
db.session.execute(
|
||||
folder_editors.delete().where(
|
||||
folder_editors.c.folder_id == child_folder_id
|
||||
)
|
||||
)
|
||||
db.session.execute(
|
||||
folder_viewers.delete().where(
|
||||
folder_viewers.c.folder_id == child_folder_id
|
||||
)
|
||||
)
|
||||
|
||||
# Copy from parent
|
||||
parent_editors = db.session.execute(
|
||||
folder_editors.select().where(
|
||||
folder_editors.c.folder_id == parent_folder_id
|
||||
)
|
||||
).fetchall()
|
||||
for row in parent_editors:
|
||||
db.session.execute(
|
||||
folder_editors.insert().values(
|
||||
folder_id=child_folder_id, user_id=row.user_id
|
||||
)
|
||||
)
|
||||
|
||||
parent_viewers = db.session.execute(
|
||||
folder_viewers.select().where(
|
||||
folder_viewers.c.folder_id == parent_folder_id
|
||||
)
|
||||
).fetchall()
|
||||
for row in parent_viewers:
|
||||
db.session.execute(
|
||||
folder_viewers.insert().values(
|
||||
folder_id=child_folder_id, user_id=row.user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Set inherits flag on child
|
||||
if child := db.session.query(Folder).get(child_folder_id):
|
||||
extra = json_utils.loads(child.extra) if child.extra else {}
|
||||
extra["inherits_permissions"] = True
|
||||
child.extra = json_utils.dumps(extra)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def push_down_permissions(folder_id: int) -> None:
|
||||
"""Re-sync permissions to all descendant folders still inheriting.
|
||||
|
||||
Called when a folder's permissions change. Only affects descendants
|
||||
where extra.inherits_permissions = true.
|
||||
"""
|
||||
|
||||
folder = db.session.query(Folder).get(folder_id)
|
||||
if not folder:
|
||||
return
|
||||
|
||||
# Get current folder's permissions
|
||||
current_subjects = FolderPermissionDAO.get_subjects(folder_id)
|
||||
|
||||
# Find all descendants that still inherit
|
||||
def _get_inheriting_descendants(parent_id: int) -> list[int]:
|
||||
children = (
|
||||
db.session.query(Folder).filter(Folder.parent_id == parent_id).all()
|
||||
)
|
||||
result = []
|
||||
for child in children:
|
||||
if child.is_private:
|
||||
continue
|
||||
extra = json_utils.loads(child.extra) if child.extra else {}
|
||||
if extra.get("inherits_permissions", True):
|
||||
result.append(child.id)
|
||||
result.extend(_get_inheriting_descendants(child.id))
|
||||
return result
|
||||
|
||||
descendant_ids = _get_inheriting_descendants(folder_id)
|
||||
|
||||
for desc_id in descendant_ids:
|
||||
# Clear existing permissions
|
||||
db.session.execute(
|
||||
folder_editors.delete().where(folder_editors.c.folder_id == desc_id)
|
||||
)
|
||||
db.session.execute(
|
||||
folder_viewers.delete().where(folder_viewers.c.folder_id == desc_id)
|
||||
)
|
||||
# Copy from parent
|
||||
for subject in current_subjects:
|
||||
if subject["permission"] == "editor":
|
||||
db.session.execute(
|
||||
folder_editors.insert().values(
|
||||
folder_id=desc_id, user_id=subject["user_id"]
|
||||
)
|
||||
)
|
||||
else:
|
||||
db.session.execute(
|
||||
folder_viewers.insert().values(
|
||||
folder_id=desc_id, user_id=subject["user_id"]
|
||||
)
|
||||
)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def mark_permissions_explicit(folder_id: int) -> None:
|
||||
"""Mark a folder as having explicitly set permissions (no longer inheriting)."""
|
||||
|
||||
if folder := db.session.query(Folder).get(folder_id):
|
||||
extra = json_utils.loads(folder.extra) if folder.extra else {}
|
||||
extra["inherits_permissions"] = False
|
||||
folder.extra = json_utils.dumps(extra)
|
||||
db.session.flush()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Access checks
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def user_has_folder_access(user_id: int, folder_id: int) -> bool:
|
||||
"""Check if a user has viewer or editor access to a folder."""
|
||||
is_editor = db.session.execute(
|
||||
folder_editors.select().where(
|
||||
and_(
|
||||
folder_editors.c.folder_id == folder_id,
|
||||
folder_editors.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if is_editor:
|
||||
return True
|
||||
|
||||
is_viewer = db.session.execute(
|
||||
folder_viewers.select().where(
|
||||
and_(
|
||||
folder_viewers.c.folder_id == folder_id,
|
||||
folder_viewers.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
return is_viewer is not None
|
||||
|
||||
@staticmethod
|
||||
def user_is_folder_editor(user_id: int, folder_id: int) -> bool:
|
||||
"""Check if a user is an editor of a specific folder."""
|
||||
return (
|
||||
db.session.execute(
|
||||
folder_editors.select().where(
|
||||
and_(
|
||||
folder_editors.c.folder_id == folder_id,
|
||||
folder_editors.c.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user_has_any_folder_access(user_id: int) -> bool:
|
||||
"""Check if user has any folder-level access (editor or viewer)."""
|
||||
has_access = (
|
||||
db.session.query(folder_editors.c.id)
|
||||
.filter(folder_editors.c.user_id == user_id)
|
||||
.first()
|
||||
) or (
|
||||
db.session.query(folder_viewers.c.id)
|
||||
.filter(folder_viewers.c.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
return has_access is not None
|
||||
|
||||
@staticmethod
|
||||
def user_has_any_folder_editor_access(user_id: int) -> bool:
|
||||
"""Check if user is an editor on any folder."""
|
||||
return (
|
||||
db.session.query(folder_editors.c.id)
|
||||
.filter(folder_editors.c.user_id == user_id)
|
||||
.first()
|
||||
) is not None
|
||||
|
||||
@staticmethod
|
||||
def _check_folder_object_access(user_id: int, filter_condition: Any) -> bool:
|
||||
"""Check if a matching FolderObject is in an accessible folder."""
|
||||
fo = db.session.query(FolderObject).filter(filter_condition).first()
|
||||
return bool(
|
||||
fo and FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user_has_folder_access_for_asset(
|
||||
user_id: int,
|
||||
dashboard_id: int | None = None,
|
||||
chart_id: int | None = None,
|
||||
datasource_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Check if user has folder access that covers this asset.
|
||||
|
||||
Checks multiple paths:
|
||||
1. Dashboard/chart is directly in a folder the user has access to
|
||||
2. Datasource is used by a chart in a folder the user has access to
|
||||
"""
|
||||
check = FolderPermissionDAO._check_folder_object_access
|
||||
|
||||
if dashboard_id and check(user_id, FolderObject.dashboard_id == dashboard_id):
|
||||
return True
|
||||
|
||||
if chart_id and check(user_id, FolderObject.chart_id == chart_id):
|
||||
return True
|
||||
|
||||
if datasource_id:
|
||||
from superset.models.slice import Slice as SliceModel
|
||||
|
||||
folder_objects = (
|
||||
db.session.query(FolderObject)
|
||||
.join(SliceModel, SliceModel.id == FolderObject.chart_id)
|
||||
.filter(SliceModel.datasource_id == datasource_id)
|
||||
.all()
|
||||
)
|
||||
for fo in folder_objects:
|
||||
if FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def user_has_transitive_dashboard_access(
|
||||
user_id: int,
|
||||
datasource_id: int,
|
||||
) -> bool:
|
||||
"""Check if a datasource is reachable via: datasource → chart → dashboard → folder.
|
||||
|
||||
Grants access when a dashboard in the user's folder contains a chart
|
||||
that uses this datasource, even if the chart itself is not in any folder.
|
||||
"""
|
||||
from superset.models.dashboard import dashboard_slices
|
||||
from superset.models.slice import Slice as SliceModel
|
||||
|
||||
# Find dashboards that contain a chart using this datasource
|
||||
# and are in a folder the user has access to.
|
||||
rows = (
|
||||
db.session.query(FolderObject.folder_id)
|
||||
.join(
|
||||
dashboard_slices,
|
||||
dashboard_slices.c.dashboard_id == FolderObject.dashboard_id,
|
||||
)
|
||||
.join(SliceModel, SliceModel.id == dashboard_slices.c.slice_id)
|
||||
.filter(
|
||||
FolderObject.dashboard_id.isnot(None),
|
||||
SliceModel.datasource_id == datasource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return any(
|
||||
FolderPermissionDAO.user_has_folder_access(user_id, row.folder_id)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user_is_folder_editor_for_asset(
|
||||
user_id: int,
|
||||
dashboard_id: int | None = None,
|
||||
chart_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Check if user is a folder editor for an asset."""
|
||||
if dashboard_id:
|
||||
fo = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(FolderObject.dashboard_id == dashboard_id)
|
||||
.first()
|
||||
)
|
||||
if fo:
|
||||
return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
|
||||
|
||||
if chart_id:
|
||||
fo = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(FolderObject.chart_id == chart_id)
|
||||
.first()
|
||||
)
|
||||
if fo:
|
||||
return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_folder_editors_as_owners(
|
||||
dashboard_id: int | None = None,
|
||||
chart_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return folder editors for an asset as owner-shaped dicts.
|
||||
|
||||
Used to populate the `extra_owners` field in API responses.
|
||||
"""
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
if dashboard_id:
|
||||
fo = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(FolderObject.dashboard_id == dashboard_id)
|
||||
.first()
|
||||
)
|
||||
elif chart_id:
|
||||
fo = (
|
||||
db.session.query(FolderObject)
|
||||
.filter(FolderObject.chart_id == chart_id)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
if not fo:
|
||||
return []
|
||||
|
||||
editor_user_ids = [
|
||||
row.user_id
|
||||
for row in db.session.execute(
|
||||
folder_editors.select().where(
|
||||
folder_editors.c.folder_id == fo.folder_id
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
if not editor_user_ids:
|
||||
return []
|
||||
|
||||
users = db.session.query(User).filter(User.id.in_(editor_user_ids)).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"first_name": u.first_name,
|
||||
"last_name": u.last_name,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
16
superset/folders/__init__.py
Normal file
16
superset/folders/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
1349
superset/folders/api.py
Normal file
1349
superset/folders/api.py
Normal file
File diff suppressed because it is too large
Load Diff
83
superset/folders/constants.py
Normal file
83
superset/folders/constants.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
"""Asset-type configuration shared by the folders DAO, schemas, and API.
|
||||
|
||||
A ``FolderObject`` links a folder to exactly one asset via a dedicated FK column
|
||||
(``dashboard_id``/``chart_id``/``dataset_id``). This module maps each asset type
|
||||
to its FK column and owning model, and defines which asset kinds belong to each
|
||||
folder namespace (``folder_type``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
DEFAULT_FOLDER_TYPE = "analytics"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetType:
|
||||
"""Describes how an asset kind maps onto ``FolderObject`` and its model."""
|
||||
|
||||
name: str
|
||||
fk_column: str
|
||||
title_attr: str
|
||||
model_loader: Callable[[], type[Any]]
|
||||
|
||||
@property
|
||||
def model(self) -> type[Any]:
|
||||
return self.model_loader()
|
||||
|
||||
|
||||
def _load_dashboard() -> type[Any]:
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
return Dashboard
|
||||
|
||||
|
||||
def _load_chart() -> type[Any]:
|
||||
from superset.models.slice import Slice
|
||||
|
||||
return Slice
|
||||
|
||||
|
||||
def _load_dataset() -> type[Any]:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
|
||||
return SqlaTable
|
||||
|
||||
|
||||
ASSET_TYPE_CONFIGS: dict[str, AssetType] = {
|
||||
"dashboard": AssetType(
|
||||
"dashboard", "dashboard_id", "dashboard_title", _load_dashboard
|
||||
),
|
||||
"chart": AssetType("chart", "chart_id", "slice_name", _load_chart),
|
||||
"dataset": AssetType("dataset", "dataset_id", "table_name", _load_dataset),
|
||||
}
|
||||
|
||||
ASSET_TYPES = set(ASSET_TYPE_CONFIGS)
|
||||
|
||||
# Which asset kinds surface under each folder namespace.
|
||||
FOLDER_TYPE_ASSETS: dict[str, list[str]] = {
|
||||
"analytics": ["dashboard", "chart"],
|
||||
}
|
||||
|
||||
|
||||
def asset_types_for_folder_type(folder_type: str) -> list[str]:
|
||||
"""Return the asset kinds shown for a given ``folder_type``."""
|
||||
return FOLDER_TYPE_ASSETS.get(folder_type, [])
|
||||
195
superset/folders/hooks.py
Normal file
195
superset/folders/hooks.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Extension hook implementations for folder-based access control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from superset import db
|
||||
from superset.utils.core import get_user_id
|
||||
|
||||
|
||||
def _user_folder_ids(user_id: int) -> Any:
|
||||
"""Subquery of folder IDs the user has access to."""
|
||||
from superset.folders.models import folder_editors, folder_viewers
|
||||
|
||||
return (
|
||||
select(folder_viewers.c.folder_id)
|
||||
.where(folder_viewers.c.user_id == user_id)
|
||||
.union(
|
||||
select(folder_editors.c.folder_id).where(
|
||||
folder_editors.c.user_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def folder_access_charts(user_id: int) -> Any:
|
||||
"""Return select() of chart IDs accessible via folder membership."""
|
||||
from superset.folders.models import FolderObject
|
||||
|
||||
return select(FolderObject.chart_id).where(
|
||||
FolderObject.chart_id.isnot(None),
|
||||
FolderObject.folder_id.in_(_user_folder_ids(user_id)),
|
||||
)
|
||||
|
||||
|
||||
def folder_access_dashboards(user_id: int) -> Any:
|
||||
"""Return select() of dashboard IDs accessible via folder membership."""
|
||||
from superset.folders.models import FolderObject
|
||||
|
||||
return select(FolderObject.dashboard_id).where(
|
||||
FolderObject.dashboard_id.isnot(None),
|
||||
FolderObject.folder_id.in_(_user_folder_ids(user_id)),
|
||||
)
|
||||
|
||||
|
||||
def folder_raise_for_access_bypass(**kwargs: Any) -> bool:
|
||||
"""Bypass raise_for_access if user has folder access to the asset."""
|
||||
from flask import current_app
|
||||
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO
|
||||
|
||||
user_id = get_user_id()
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
dashboard = kwargs.get("dashboard")
|
||||
chart = kwargs.get("chart")
|
||||
|
||||
# Never bypass for assets in private folders unless the user is the owner
|
||||
if is_asset_in_private_folder(
|
||||
dashboard_id=dashboard.id if dashboard else None,
|
||||
chart_id=chart.id if chart else None,
|
||||
):
|
||||
from superset.daos.folder_permissions import FolderPermissionDAO as _PDAO
|
||||
|
||||
has_access = _PDAO.user_has_folder_access_for_asset(
|
||||
user_id=user_id,
|
||||
dashboard_id=dashboard.id if dashboard else None,
|
||||
chart_id=chart.id if chart else None,
|
||||
)
|
||||
return has_access
|
||||
|
||||
query_context = kwargs.get("query_context")
|
||||
viz = kwargs.get("viz")
|
||||
datasource = kwargs.get("datasource")
|
||||
|
||||
if not datasource and query_context:
|
||||
datasource = query_context.datasource
|
||||
if not datasource and viz:
|
||||
datasource = viz.datasource
|
||||
|
||||
if FolderPermissionDAO.user_has_folder_access_for_asset(
|
||||
user_id=user_id,
|
||||
dashboard_id=dashboard.id if dashboard else None,
|
||||
chart_id=chart.id if chart else None,
|
||||
datasource_id=datasource.id if datasource else None,
|
||||
):
|
||||
return True
|
||||
|
||||
# Transitive access: datasource → chart → dashboard → folder.
|
||||
# Grants access to datasources used by charts on dashboards in the
|
||||
# user's folders, even if the chart is not directly in any folder.
|
||||
if (
|
||||
datasource
|
||||
and current_app.config.get("FOLDER_DASHBOARD_TRANSITIVE_ACCESS")
|
||||
and FolderPermissionDAO.user_has_transitive_dashboard_access(
|
||||
user_id=user_id,
|
||||
datasource_id=datasource.id,
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def folder_extra_owners(resource: Any) -> list[Any]:
|
||||
"""Return folder editors as additional owners."""
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.folders.models import folder_editors, FolderObject
|
||||
|
||||
tablename = resource.__tablename__
|
||||
if tablename == "slices":
|
||||
fk_col = FolderObject.chart_id
|
||||
elif tablename == "dashboards":
|
||||
fk_col = FolderObject.dashboard_id
|
||||
else:
|
||||
return []
|
||||
|
||||
fo = db.session.query(FolderObject).filter(fk_col == resource.id).first()
|
||||
if not fo:
|
||||
return []
|
||||
|
||||
editor_ids = [
|
||||
row.user_id
|
||||
for row in db.session.execute(
|
||||
folder_editors.select().where(folder_editors.c.folder_id == fo.folder_id)
|
||||
).fetchall()
|
||||
]
|
||||
if not editor_ids:
|
||||
return []
|
||||
|
||||
users = db.session.query(User).filter(User.id.in_(editor_ids)).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"first_name": u.first_name,
|
||||
"last_name": u.last_name,
|
||||
"username": u.username,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
def after_asset_create(asset: Any, asset_type: str) -> None:
|
||||
"""Auto-assign newly created charts/dashboards to the user's 'Only Me' folder."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("[after_asset_create] called with asset_type=%s, asset_id=%s", asset_type, asset.id)
|
||||
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.utils import can_manage_folders
|
||||
|
||||
user_id = get_user_id()
|
||||
logger.info("[after_asset_create] user_id=%s", user_id)
|
||||
if not user_id:
|
||||
logger.info("[after_asset_create] no user_id, returning")
|
||||
return
|
||||
|
||||
from flask import g
|
||||
|
||||
if not hasattr(g, "user"):
|
||||
logger.info("[after_asset_create] no g.user, returning")
|
||||
return
|
||||
if not can_manage_folders(g.user):
|
||||
logger.info("[after_asset_create] user cannot manage folders, roles=%s", [r.name for r in g.user.roles])
|
||||
return
|
||||
|
||||
logger.info("[after_asset_create] creating/getting Only Me folder")
|
||||
folder = FolderDAO.get_or_create_only_me_folder(user_id)
|
||||
logger.info("[after_asset_create] assigning asset to folder %s", folder.id)
|
||||
FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset.id}])
|
||||
logger.info("[after_asset_create] done")
|
||||
|
||||
|
||||
def is_asset_in_private_folder(
|
||||
dashboard_id: int | None = None,
|
||||
chart_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Check if an asset is in a private folder."""
|
||||
from superset.folders.models import Folder, FolderObject
|
||||
|
||||
query = db.session.query(FolderObject).join(
|
||||
Folder, Folder.id == FolderObject.folder_id
|
||||
).filter(Folder.is_private.is_(True))
|
||||
|
||||
if dashboard_id:
|
||||
if query.filter(FolderObject.dashboard_id == dashboard_id).first():
|
||||
return True
|
||||
if chart_id:
|
||||
if query.filter(FolderObject.chart_id == chart_id).first():
|
||||
return True
|
||||
return False
|
||||
255
superset/folders/models.py
Normal file
255
superset/folders/models.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
"""Preset-owned folder models.
|
||||
|
||||
These tables live in the Superset metadata DB but belong to Preset's separate
|
||||
``MetaData``/Alembic history (see ``preset/migrations_preset``). Columns that
|
||||
reference Superset-owned tables (``ab_user``, ``dashboards``, ``slices``,
|
||||
``tables``) are plain integer columns *without* a declarative ``ForeignKey``:
|
||||
the DB-level constraints are created by the Preset migration, not the ORM. This
|
||||
avoids ``NoReferencedTableError``, which fires when SQLAlchemy tries to resolve
|
||||
a ``ForeignKey`` target that doesn't exist in Preset's ``MetaData``. Only
|
||||
``folders``-internal references (which *are* in Preset's ``MetaData``) keep a
|
||||
declarative ``ForeignKey``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from superset.models.helpers import AuditMixinNullable
|
||||
|
||||
metadata = Model.metadata # pylint: disable=no-member
|
||||
|
||||
|
||||
# Junction table: folder editors (users who can manage folder contents).
|
||||
# ``user_id`` references ``ab_user`` (Superset-owned); the FK constraint is
|
||||
# created by the Preset migration, so no declarative ``ForeignKey`` here.
|
||||
folder_editors = Table(
|
||||
"folder_editors",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column(
|
||||
"folder_id",
|
||||
Integer,
|
||||
ForeignKey("folders.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
UniqueConstraint("folder_id", "user_id"),
|
||||
Index("ix_folder_editors_user_id", "user_id"),
|
||||
)
|
||||
|
||||
# Junction table: folder viewers (users who can see folder and its assets).
|
||||
folder_viewers = Table(
|
||||
"folder_viewers",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column(
|
||||
"folder_id",
|
||||
Integer,
|
||||
ForeignKey("folders.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
UniqueConstraint("folder_id", "user_id"),
|
||||
Index("ix_folder_viewers_user_id", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class Folder(AuditMixinNullable, Model):
|
||||
"""A folder for organizing dashboards, charts, and datasets."""
|
||||
|
||||
__tablename__ = "folders"
|
||||
__table_args__ = (UniqueConstraint("parent_id", "name", "folder_type"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(
|
||||
UUIDType(binary=True), unique=True, nullable=False, default=uuid.uuid4
|
||||
)
|
||||
name = Column(String(250), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
parent_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
|
||||
folder_type = Column(String(50), nullable=False, default="analytics")
|
||||
is_private = Column(Boolean, nullable=False, default=False)
|
||||
extra = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
parent = relationship(
|
||||
"Folder",
|
||||
remote_side=[id],
|
||||
back_populates="children",
|
||||
)
|
||||
children = relationship(
|
||||
"Folder",
|
||||
back_populates="parent",
|
||||
)
|
||||
objects = relationship(
|
||||
"FolderObject",
|
||||
back_populates="folder",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
# ``editors``/``viewers`` cross into Superset's ``ab_user`` via the junction
|
||||
# tables. They are ``viewonly`` (writes go through ``FolderPermissionDAO``
|
||||
# Core queries) and use the ``User`` class object with explicit joins so
|
||||
# SQLAlchemy never resolves ``ab_user`` inside Preset's MetaData.
|
||||
editors = relationship(
|
||||
User,
|
||||
secondary=folder_editors,
|
||||
primaryjoin=lambda: Folder.id == folder_editors.c.folder_id,
|
||||
secondaryjoin=lambda: folder_editors.c.user_id == User.id,
|
||||
viewonly=True,
|
||||
)
|
||||
viewers = relationship(
|
||||
User,
|
||||
secondary=folder_viewers,
|
||||
primaryjoin=lambda: Folder.id == folder_viewers.c.folder_id,
|
||||
secondaryjoin=lambda: folder_viewers.c.user_id == User.id,
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Folder<{self.id or self.name}>"
|
||||
|
||||
|
||||
class FolderObject(Model):
|
||||
"""Links a folder to an asset (dashboard, chart, or dataset).
|
||||
|
||||
Uses separate FK columns instead of polymorphic object_id/object_type
|
||||
so that ON DELETE CASCADE works at the DB level.
|
||||
Only one of dashboard_id, chart_id, dataset_id is set per row.
|
||||
|
||||
``dashboard_id``/``chart_id``/``dataset_id`` reference Superset-owned tables;
|
||||
their FK constraints are created by the Preset migration (no declarative
|
||||
``ForeignKey`` here, see module docstring).
|
||||
"""
|
||||
|
||||
__tablename__ = "folder_objects"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN dataset_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||
name="ck_folder_objects_exactly_one_fk",
|
||||
),
|
||||
Index("ix_folder_objects_folder_id", "folder_id"),
|
||||
Index("ix_folder_objects_chart_id", "chart_id"),
|
||||
Index("ix_folder_objects_dashboard_id", "dashboard_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
folder_id = Column(
|
||||
Integer,
|
||||
ForeignKey("folders.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
dashboard_id = Column(Integer, nullable=True)
|
||||
chart_id = Column(Integer, nullable=True)
|
||||
dataset_id = Column(Integer, nullable=True)
|
||||
created_on = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
folder = relationship("Folder", back_populates="objects")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
asset = (
|
||||
f"dashboard={self.dashboard_id}"
|
||||
if self.dashboard_id
|
||||
else f"chart={self.chart_id}"
|
||||
if self.chart_id
|
||||
else f"dataset={self.dataset_id}"
|
||||
)
|
||||
return f"FolderObject<folder={self.folder_id}, {asset}>"
|
||||
|
||||
|
||||
class FolderPin(Model):
|
||||
"""Per-user pinned items on the Analytics root view.
|
||||
|
||||
Max 3 pins per user (enforced by UNIQUE on user_id + position).
|
||||
Pins are user-specific and only shown on the main Analytics view.
|
||||
|
||||
Uses separate FK columns instead of polymorphic object_id/object_type
|
||||
so that ON DELETE CASCADE works at the DB level.
|
||||
Only one of folder_id, dashboard_id, chart_id is set per row.
|
||||
|
||||
``user_id``/``dashboard_id``/``chart_id`` reference Superset-owned tables;
|
||||
their FK constraints are created by the migration.
|
||||
"""
|
||||
|
||||
__tablename__ = "folder_pins"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "position"),
|
||||
CheckConstraint(
|
||||
"position >= 1 AND position <= 3",
|
||||
name="ck_folder_pins_position_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(CASE WHEN folder_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||
name="ck_folder_pins_exactly_one_fk",
|
||||
),
|
||||
Index("ix_folder_pins_folder_id", "folder_id"),
|
||||
Index("ix_folder_pins_dashboard_id", "dashboard_id"),
|
||||
Index("ix_folder_pins_chart_id", "chart_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
folder_id = Column(
|
||||
Integer,
|
||||
ForeignKey("folders.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
dashboard_id = Column(Integer, nullable=True)
|
||||
chart_id = Column(Integer, nullable=True)
|
||||
position = Column(Integer, nullable=False)
|
||||
created_on = Column(DateTime, nullable=True)
|
||||
|
||||
@property
|
||||
def object_type(self) -> str:
|
||||
if self.folder_id is not None:
|
||||
return "folder"
|
||||
if self.dashboard_id is not None:
|
||||
return "dashboard"
|
||||
return "chart"
|
||||
|
||||
@property
|
||||
def object_id(self) -> int:
|
||||
if self.folder_id is not None:
|
||||
return self.folder_id
|
||||
if self.dashboard_id is not None:
|
||||
return self.dashboard_id
|
||||
return self.chart_id
|
||||
242
superset/folders/schemas.py
Normal file
242
superset/folders/schemas.py
Normal file
@@ -0,0 +1,242 @@
|
||||
# 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.
|
||||
"""Marshmallow schemas for the folders REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from marshmallow import fields, Schema, validate
|
||||
|
||||
from superset.folders.constants import ASSET_TYPES, DEFAULT_FOLDER_TYPE
|
||||
|
||||
folder_type_description = "Namespace the folder lives in (e.g. 'analytics')."
|
||||
parent_uuid_description = (
|
||||
"UUID of the parent folder. Omit or set to null to place at the root."
|
||||
)
|
||||
asset_type_description = "Kind of asset: one of " + ", ".join(sorted(ASSET_TYPES)) + "."
|
||||
|
||||
|
||||
class FolderAssetRefSchema(Schema):
|
||||
"""Reference to a single asset to assign to a folder."""
|
||||
|
||||
type = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(sorted(ASSET_TYPES)),
|
||||
metadata={"description": asset_type_description},
|
||||
)
|
||||
id = fields.Integer(
|
||||
required=True, metadata={"description": "Primary key of the asset."}
|
||||
)
|
||||
|
||||
|
||||
class FolderPostSchema(Schema):
|
||||
"""Payload to create a folder."""
|
||||
|
||||
name = fields.String(required=True, validate=validate.Length(min=1, max=250))
|
||||
description = fields.String(allow_none=True, load_default=None)
|
||||
parent_uuid = fields.String(
|
||||
allow_none=True,
|
||||
load_default=None,
|
||||
metadata={"description": parent_uuid_description},
|
||||
)
|
||||
folder_type = fields.String(
|
||||
load_default=DEFAULT_FOLDER_TYPE,
|
||||
validate=validate.Length(min=1, max=50),
|
||||
metadata={"description": folder_type_description},
|
||||
)
|
||||
is_private = fields.Boolean(load_default=False)
|
||||
|
||||
|
||||
class FolderPutSchema(Schema):
|
||||
"""Payload to update a folder. All fields optional (partial update)."""
|
||||
|
||||
name = fields.String(validate=validate.Length(min=1, max=250))
|
||||
description = fields.String(allow_none=True)
|
||||
parent_uuid = fields.String(
|
||||
allow_none=True, metadata={"description": parent_uuid_description}
|
||||
)
|
||||
is_private = fields.Boolean()
|
||||
sync_permissions = fields.Boolean(load_default=False)
|
||||
|
||||
|
||||
class FolderAssetsPutSchema(Schema):
|
||||
"""Payload setting a folder's full asset membership.
|
||||
|
||||
``assets`` is the complete desired set: listed assets are moved into the
|
||||
folder, and the folder's current assets not listed are moved back to the
|
||||
root. An empty list empties the folder.
|
||||
"""
|
||||
|
||||
assets = fields.List(fields.Nested(FolderAssetRefSchema), load_default=list)
|
||||
|
||||
|
||||
class FolderUserSchema(Schema):
|
||||
id = fields.Integer()
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
|
||||
|
||||
class FolderSchema(Schema):
|
||||
"""Folder metadata in responses."""
|
||||
|
||||
id = fields.Integer()
|
||||
uuid = fields.String()
|
||||
name = fields.String()
|
||||
description = fields.String(allow_none=True)
|
||||
parent_uuid = fields.String(allow_none=True)
|
||||
folder_type = fields.String()
|
||||
is_private = fields.Boolean()
|
||||
is_only_me = fields.Boolean()
|
||||
children_count = fields.Integer()
|
||||
asset_count = fields.Integer()
|
||||
created_on = fields.DateTime()
|
||||
changed_on = fields.DateTime()
|
||||
created_by = fields.Nested(FolderUserSchema, allow_none=True)
|
||||
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
|
||||
|
||||
|
||||
class FolderAssetSchema(Schema):
|
||||
"""An asset (dashboard/chart/dataset) in responses."""
|
||||
|
||||
type = fields.String()
|
||||
id = fields.Integer()
|
||||
uuid = fields.String(allow_none=True)
|
||||
name = fields.String()
|
||||
url = fields.String(allow_none=True)
|
||||
changed_on = fields.DateTime(allow_none=True)
|
||||
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
|
||||
owners = fields.List(fields.Nested(FolderUserSchema))
|
||||
# Chart-only columns; null for other asset kinds.
|
||||
viz_type = fields.String(allow_none=True)
|
||||
database = fields.String(allow_none=True)
|
||||
schema = fields.String(allow_none=True)
|
||||
|
||||
|
||||
class FolderContentItemSchema(Schema):
|
||||
"""A single contents row — a folder or an asset (superset of both shapes)."""
|
||||
|
||||
type = fields.String()
|
||||
id = fields.Integer()
|
||||
uuid = fields.String(allow_none=True)
|
||||
name = fields.String()
|
||||
url = fields.String(allow_none=True)
|
||||
parent_uuid = fields.String(allow_none=True)
|
||||
folder_type = fields.String(allow_none=True)
|
||||
is_private = fields.Boolean(allow_none=True)
|
||||
is_only_me = fields.Boolean(allow_none=True)
|
||||
children_count = fields.Integer(allow_none=True)
|
||||
asset_count = fields.Integer(allow_none=True)
|
||||
viz_type = fields.String(allow_none=True)
|
||||
database = fields.String(allow_none=True)
|
||||
schema = fields.String(allow_none=True)
|
||||
changed_on = fields.DateTime(allow_none=True)
|
||||
created_on = fields.DateTime(allow_none=True)
|
||||
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
|
||||
created_by = fields.Nested(FolderUserSchema, allow_none=True)
|
||||
owners = fields.List(fields.Nested(FolderUserSchema))
|
||||
|
||||
|
||||
class FolderListResponseSchema(Schema):
|
||||
count = fields.Integer()
|
||||
result = fields.List(fields.Nested(FolderSchema))
|
||||
|
||||
|
||||
class FolderResponseSchema(Schema):
|
||||
result = fields.Nested(FolderSchema)
|
||||
|
||||
|
||||
class FolderContentsResponseSchema(Schema):
|
||||
"""A page of a folder's contents (subfolders + assets), filtered/paginated."""
|
||||
|
||||
folder = fields.Nested(FolderSchema)
|
||||
result = fields.List(fields.Nested(FolderContentItemSchema))
|
||||
count = fields.Integer()
|
||||
page = fields.Integer()
|
||||
page_size = fields.Integer()
|
||||
|
||||
|
||||
class FolderRootResponseSchema(Schema):
|
||||
"""A page of the root view (top-level folders + unfoldered assets)."""
|
||||
|
||||
result = fields.List(fields.Nested(FolderContentItemSchema))
|
||||
count = fields.Integer()
|
||||
page = fields.Integer()
|
||||
page_size = fields.Integer()
|
||||
|
||||
|
||||
# --- Pin schemas ---
|
||||
|
||||
PIN_OBJECT_TYPES = {"folder", "chart", "dashboard"}
|
||||
|
||||
|
||||
class FolderPinPostSchema(Schema):
|
||||
"""Payload to pin an item."""
|
||||
|
||||
object_id = fields.Integer(required=True)
|
||||
object_type = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(sorted(PIN_OBJECT_TYPES)),
|
||||
)
|
||||
position = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=1, max=3),
|
||||
)
|
||||
|
||||
|
||||
class FolderPinSchema(Schema):
|
||||
"""A pinned item in responses."""
|
||||
|
||||
id = fields.Integer()
|
||||
object_id = fields.Integer()
|
||||
object_type = fields.String()
|
||||
position = fields.Integer()
|
||||
created_on = fields.DateTime(allow_none=True)
|
||||
|
||||
|
||||
class FolderPinListResponseSchema(Schema):
|
||||
count = fields.Integer()
|
||||
result = fields.List(fields.Nested(FolderPinSchema))
|
||||
|
||||
|
||||
# --- Subject schemas ---
|
||||
|
||||
SUBJECT_PERMISSIONS = {"editor", "viewer"}
|
||||
|
||||
|
||||
class FolderSubjectPostSchema(Schema):
|
||||
"""Payload to add a subject to a folder."""
|
||||
|
||||
user_id = fields.Integer(required=True)
|
||||
permission = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(sorted(SUBJECT_PERMISSIONS)),
|
||||
)
|
||||
|
||||
|
||||
class FolderSubjectPutSchema(Schema):
|
||||
"""Payload to update a subject's permission."""
|
||||
|
||||
permission = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(sorted(SUBJECT_PERMISSIONS)),
|
||||
)
|
||||
|
||||
|
||||
class FolderSubjectSchema(Schema):
|
||||
"""A subject (editor/viewer) in responses."""
|
||||
|
||||
user_id = fields.Integer()
|
||||
permission = fields.String()
|
||||
17
superset/folders/utils.py
Normal file
17
superset/folders/utils.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Folder utility functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
FOLDER_MANAGEMENT_ROLES = {"Admin", "Alpha", "Gamma"}
|
||||
|
||||
|
||||
def can_manage_folders(user: Any) -> bool:
|
||||
"""Check if user can create, delete, and manage folders.
|
||||
|
||||
Allowed for Admin, Alpha, and Gamma roles.
|
||||
"""
|
||||
if not user or not getattr(user, "roles", None):
|
||||
return False
|
||||
return any(role.name in FOLDER_MANAGEMENT_ROLES for role in user.roles)
|
||||
@@ -180,6 +180,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
from superset.explore.form_data.api import ExploreFormDataRestApi
|
||||
from superset.explore.permalink.api import ExplorePermalinkRestApi
|
||||
from superset.extensions.view import ExtensionsView
|
||||
from superset.folders.api import FolderRestApi
|
||||
from superset.importexport.api import ImportExportRestApi
|
||||
from superset.queries.api import QueryRestApi
|
||||
from superset.queries.saved_queries.api import SavedQueryRestApi
|
||||
@@ -211,6 +212,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
from superset.views.dynamic_plugins import DynamicPluginsView
|
||||
from superset.views.error_handling import set_app_error_handlers
|
||||
from superset.views.explore import ExplorePermalinkView, ExploreView
|
||||
from superset.views.folders import FolderView
|
||||
from superset.views.groups import GroupsListView
|
||||
from superset.views.log.api import LogRestApi
|
||||
from superset.views.logs import ActionLogView
|
||||
@@ -269,6 +271,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_api(ExploreRestApi)
|
||||
appbuilder.add_api(ExploreFormDataRestApi)
|
||||
appbuilder.add_api(ExplorePermalinkRestApi)
|
||||
appbuilder.add_api(FolderRestApi)
|
||||
appbuilder.add_api(ImportExportRestApi)
|
||||
appbuilder.add_api(QueryRestApi)
|
||||
appbuilder.add_api(ReportScheduleRestApi)
|
||||
@@ -312,6 +315,18 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
cond=lambda: bool(current_app.config["LOGO_TARGET_PATH"]),
|
||||
)
|
||||
|
||||
# Analytics replaces the standalone Dashboards and Charts menu items as
|
||||
# the primary entry point. Their model views stay registered (below,
|
||||
# without a menu entry) so their routes keep working.
|
||||
appbuilder.add_link(
|
||||
"Analytics",
|
||||
label=_("Analytics"),
|
||||
href="/analytics/",
|
||||
icon="fa-folder-open",
|
||||
category="",
|
||||
category_icon="",
|
||||
)
|
||||
|
||||
appbuilder.add_view(
|
||||
DatabaseView,
|
||||
"Databases",
|
||||
@@ -320,22 +335,8 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
category="Data",
|
||||
category_label=_("Data"),
|
||||
)
|
||||
appbuilder.add_view(
|
||||
DashboardModelView,
|
||||
"Dashboards",
|
||||
label=_("Dashboards"),
|
||||
icon="fa-dashboard",
|
||||
category="",
|
||||
category_icon="",
|
||||
)
|
||||
appbuilder.add_view(
|
||||
SliceModelView,
|
||||
"Charts",
|
||||
label=_("Charts"),
|
||||
icon="fa-bar-chart",
|
||||
category="",
|
||||
category_icon="",
|
||||
)
|
||||
appbuilder.add_view_no_menu(DashboardModelView)
|
||||
appbuilder.add_view_no_menu(SliceModelView)
|
||||
|
||||
appbuilder.add_link(
|
||||
"Datasets",
|
||||
@@ -453,6 +454,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_view_no_menu(EmbeddedView)
|
||||
appbuilder.add_view_no_menu(ExploreView)
|
||||
appbuilder.add_view_no_menu(ExplorePermalinkView)
|
||||
appbuilder.add_view_no_menu(FolderView)
|
||||
appbuilder.add_view_no_menu(SavedQueryView)
|
||||
appbuilder.add_view_no_menu(SqllabView)
|
||||
appbuilder.add_view_no_menu(Superset)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# 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.
|
||||
"""add folder tables
|
||||
|
||||
Revision ID: 4567cf3d03cc
|
||||
Revises: 33d7e0e21daa
|
||||
Create Date: 2026-05-27 16:01:44.222914
|
||||
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
create_fks_for_table,
|
||||
create_index,
|
||||
create_table,
|
||||
drop_fks_for_table,
|
||||
drop_index,
|
||||
drop_table,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4567cf3d03cc"
|
||||
down_revision = "33d7e0e21daa"
|
||||
|
||||
FOLDERS_TABLE = "folders"
|
||||
FOLDER_OBJECTS_TABLE = "folder_objects"
|
||||
FOLDER_EDITORS_TABLE = "folder_editors"
|
||||
FOLDER_VIEWERS_TABLE = "folder_viewers"
|
||||
FOLDER_PINS_TABLE = "folder_pins"
|
||||
|
||||
|
||||
def upgrade():
|
||||
# --- folders ---
|
||||
create_table(
|
||||
FOLDERS_TABLE,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("uuid", UUIDType(binary=True), nullable=False, unique=True),
|
||||
Column("name", String(250), nullable=False),
|
||||
Column("description", Text, nullable=True),
|
||||
Column("parent_id", Integer, nullable=True),
|
||||
Column("folder_type", String(50), nullable=False, server_default="analytics"),
|
||||
Column("is_private", Boolean, nullable=False, server_default="0"),
|
||||
Column("extra", Text, nullable=True),
|
||||
Column("created_on", DateTime, nullable=True),
|
||||
Column("changed_on", DateTime, nullable=True),
|
||||
Column("created_by_fk", Integer, nullable=True),
|
||||
Column("changed_by_fk", Integer, nullable=True),
|
||||
UniqueConstraint(
|
||||
"parent_id", "name", "folder_type", name="uq_folder_parent_name_type"
|
||||
),
|
||||
)
|
||||
create_index(FOLDERS_TABLE, "idx_folders_uuid", ["uuid"], unique=True)
|
||||
create_index(FOLDERS_TABLE, "idx_folders_parent_id", ["parent_id"])
|
||||
create_index(FOLDERS_TABLE, "idx_folders_folder_type", ["folder_type"])
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folders_parent_id_folders",
|
||||
table_name=FOLDERS_TABLE,
|
||||
referenced_table=FOLDERS_TABLE,
|
||||
local_cols=["parent_id"],
|
||||
remote_cols=["id"],
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folders_created_by_fk_ab_user",
|
||||
table_name=FOLDERS_TABLE,
|
||||
referenced_table="ab_user",
|
||||
local_cols=["created_by_fk"],
|
||||
remote_cols=["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folders_changed_by_fk_ab_user",
|
||||
table_name=FOLDERS_TABLE,
|
||||
referenced_table="ab_user",
|
||||
local_cols=["changed_by_fk"],
|
||||
remote_cols=["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# --- folder_objects ---
|
||||
create_table(
|
||||
FOLDER_OBJECTS_TABLE,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("folder_id", Integer, nullable=False),
|
||||
Column("dashboard_id", Integer, nullable=True),
|
||||
Column("chart_id", Integer, nullable=True),
|
||||
Column("dataset_id", Integer, nullable=True),
|
||||
Column("created_on", DateTime, nullable=True),
|
||||
CheckConstraint(
|
||||
"(CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN dataset_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||
name="ck_folder_objects_exactly_one_fk",
|
||||
),
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_objects_folder_id_folders",
|
||||
table_name=FOLDER_OBJECTS_TABLE,
|
||||
referenced_table=FOLDERS_TABLE,
|
||||
local_cols=["folder_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_objects_dashboard_id_dashboards",
|
||||
table_name=FOLDER_OBJECTS_TABLE,
|
||||
referenced_table="dashboards",
|
||||
local_cols=["dashboard_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_objects_chart_id_slices",
|
||||
table_name=FOLDER_OBJECTS_TABLE,
|
||||
referenced_table="slices",
|
||||
local_cols=["chart_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_objects_dataset_id_tables",
|
||||
table_name=FOLDER_OBJECTS_TABLE,
|
||||
referenced_table="tables",
|
||||
local_cols=["dataset_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_index(
|
||||
FOLDER_OBJECTS_TABLE,
|
||||
"uq_folder_objects_dashboard",
|
||||
["dashboard_id"],
|
||||
unique=True,
|
||||
)
|
||||
create_index(
|
||||
FOLDER_OBJECTS_TABLE, "uq_folder_objects_chart", ["chart_id"], unique=True
|
||||
)
|
||||
create_index(
|
||||
FOLDER_OBJECTS_TABLE, "uq_folder_objects_dataset", ["dataset_id"], unique=True
|
||||
)
|
||||
|
||||
# --- folder_editors ---
|
||||
create_table(
|
||||
FOLDER_EDITORS_TABLE,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("folder_id", Integer, nullable=False),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
UniqueConstraint("folder_id", "user_id", name="uq_folder_editors_folder_user"),
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_editors_folder_id_folders",
|
||||
table_name=FOLDER_EDITORS_TABLE,
|
||||
referenced_table=FOLDERS_TABLE,
|
||||
local_cols=["folder_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_editors_user_id_ab_user",
|
||||
table_name=FOLDER_EDITORS_TABLE,
|
||||
referenced_table="ab_user",
|
||||
local_cols=["user_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# --- folder_viewers ---
|
||||
create_table(
|
||||
FOLDER_VIEWERS_TABLE,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("folder_id", Integer, nullable=False),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
UniqueConstraint("folder_id", "user_id", name="uq_folder_viewers_folder_user"),
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_viewers_folder_id_folders",
|
||||
table_name=FOLDER_VIEWERS_TABLE,
|
||||
referenced_table=FOLDERS_TABLE,
|
||||
local_cols=["folder_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_viewers_user_id_ab_user",
|
||||
table_name=FOLDER_VIEWERS_TABLE,
|
||||
referenced_table="ab_user",
|
||||
local_cols=["user_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# --- folder_pins ---
|
||||
create_table(
|
||||
FOLDER_PINS_TABLE,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
Column("folder_id", Integer, nullable=True),
|
||||
Column("dashboard_id", Integer, nullable=True),
|
||||
Column("chart_id", Integer, nullable=True),
|
||||
Column("position", Integer, nullable=False),
|
||||
Column("created_on", DateTime, nullable=True),
|
||||
UniqueConstraint("user_id", "position", name="uq_folder_pins_user_position"),
|
||||
CheckConstraint(
|
||||
"position >= 1 AND position <= 3",
|
||||
name="ck_folder_pins_position_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(CASE WHEN folder_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
|
||||
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||
name="ck_folder_pins_exactly_one_fk",
|
||||
),
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_pins_user_id_ab_user",
|
||||
table_name=FOLDER_PINS_TABLE,
|
||||
referenced_table="ab_user",
|
||||
local_cols=["user_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_pins_folder_id_folders",
|
||||
table_name=FOLDER_PINS_TABLE,
|
||||
referenced_table=FOLDERS_TABLE,
|
||||
local_cols=["folder_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_pins_dashboard_id_dashboards",
|
||||
table_name=FOLDER_PINS_TABLE,
|
||||
referenced_table="dashboards",
|
||||
local_cols=["dashboard_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_fks_for_table(
|
||||
foreign_key_name="fk_folder_pins_chart_id_slices",
|
||||
table_name=FOLDER_PINS_TABLE,
|
||||
referenced_table="slices",
|
||||
local_cols=["chart_id"],
|
||||
remote_cols=["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
create_index(FOLDER_PINS_TABLE, "idx_folder_pins_user_id", ["user_id"])
|
||||
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_folder_id", ["folder_id"])
|
||||
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_dashboard_id", ["dashboard_id"])
|
||||
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_chart_id", ["chart_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_chart_id")
|
||||
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_dashboard_id")
|
||||
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_folder_id")
|
||||
drop_index(FOLDER_PINS_TABLE, "idx_folder_pins_user_id")
|
||||
drop_fks_for_table(
|
||||
FOLDER_PINS_TABLE,
|
||||
[
|
||||
"fk_folder_pins_user_id_ab_user",
|
||||
"fk_folder_pins_folder_id_folders",
|
||||
"fk_folder_pins_dashboard_id_dashboards",
|
||||
"fk_folder_pins_chart_id_slices",
|
||||
],
|
||||
)
|
||||
drop_table(FOLDER_PINS_TABLE)
|
||||
|
||||
drop_fks_for_table(
|
||||
FOLDER_VIEWERS_TABLE,
|
||||
["fk_folder_viewers_folder_id_folders", "fk_folder_viewers_user_id_ab_user"],
|
||||
)
|
||||
drop_table(FOLDER_VIEWERS_TABLE)
|
||||
|
||||
drop_fks_for_table(
|
||||
FOLDER_EDITORS_TABLE,
|
||||
["fk_folder_editors_folder_id_folders", "fk_folder_editors_user_id_ab_user"],
|
||||
)
|
||||
drop_table(FOLDER_EDITORS_TABLE)
|
||||
|
||||
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_dashboard")
|
||||
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_chart")
|
||||
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_dataset")
|
||||
drop_fks_for_table(
|
||||
FOLDER_OBJECTS_TABLE,
|
||||
[
|
||||
"fk_folder_objects_folder_id_folders",
|
||||
"fk_folder_objects_dashboard_id_dashboards",
|
||||
"fk_folder_objects_chart_id_slices",
|
||||
"fk_folder_objects_dataset_id_tables",
|
||||
],
|
||||
)
|
||||
drop_table(FOLDER_OBJECTS_TABLE)
|
||||
|
||||
drop_index(FOLDERS_TABLE, "idx_folders_uuid")
|
||||
drop_index(FOLDERS_TABLE, "idx_folders_parent_id")
|
||||
drop_index(FOLDERS_TABLE, "idx_folders_folder_type")
|
||||
drop_fks_for_table(
|
||||
FOLDERS_TABLE,
|
||||
[
|
||||
"fk_folders_parent_id_folders",
|
||||
"fk_folders_created_by_fk_ab_user",
|
||||
"fk_folders_changed_by_fk_ab_user",
|
||||
],
|
||||
)
|
||||
drop_table(FOLDERS_TABLE)
|
||||
47
superset/views/folders.py
Normal file
47
superset/views/folders.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
from flask_appbuilder import permission_name
|
||||
from flask_appbuilder.api import expose
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
from superset import event_logger
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.views.base import BaseSupersetView
|
||||
|
||||
|
||||
class FolderView(BaseSupersetView):
|
||||
"""Serves the SPA for the folder-based Analytics page."""
|
||||
|
||||
route_base = "/analytics"
|
||||
class_permission_name = "Folder"
|
||||
method_permission_name = {
|
||||
"root": "read",
|
||||
"folder": "read",
|
||||
}
|
||||
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
@expose("/")
|
||||
@event_logger.log_this
|
||||
def root(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
@expose("/<folder_uuid>/")
|
||||
@event_logger.log_this
|
||||
def folder(self, folder_uuid: str) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
16
tests/integration_tests/folders/__init__.py
Normal file
16
tests/integration_tests/folders/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
442
tests/integration_tests/folders/api_tests.py
Normal file
442
tests/integration_tests/folders/api_tests.py
Normal file
@@ -0,0 +1,442 @@
|
||||
# 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.
|
||||
"""Integration tests for the folders REST API and DAO."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import tests.integration_tests.test_app # noqa: F401
|
||||
from superset import db
|
||||
from superset.daos.folder import FolderDAO
|
||||
from superset.folders.models import Folder, FolderObject
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME
|
||||
|
||||
DASH_PREFIX = "folders_test_dash_"
|
||||
CHART_PREFIX = "folders_test_chart_"
|
||||
|
||||
|
||||
class TestFolderApi(SupersetTestCase):
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_folders(self):
|
||||
yield
|
||||
db.session.query(FolderObject).delete()
|
||||
db.session.query(Folder).delete()
|
||||
for dashboard in (
|
||||
db.session.query(Dashboard)
|
||||
.filter(Dashboard.dashboard_title.like(f"{DASH_PREFIX}%"))
|
||||
.all()
|
||||
):
|
||||
db.session.delete(dashboard)
|
||||
for chart in (
|
||||
db.session.query(Slice)
|
||||
.filter(Slice.slice_name.like(f"{CHART_PREFIX}%"))
|
||||
.all()
|
||||
):
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
def _create_folder(self, **payload: Any) -> dict[str, Any]:
|
||||
payload.setdefault("name", "Folder")
|
||||
response = self.client.post("/api/v1/folders/", json=payload)
|
||||
assert response.status_code == 201, response.json
|
||||
return response.json["result"]
|
||||
|
||||
def _create_dashboard(self, title: str) -> Dashboard:
|
||||
admin = self.get_user(ADMIN_USERNAME)
|
||||
dashboard = Dashboard(
|
||||
dashboard_title=f"{DASH_PREFIX}{title}",
|
||||
created_by=admin,
|
||||
changed_by=admin,
|
||||
)
|
||||
db.session.add(dashboard)
|
||||
db.session.commit()
|
||||
return dashboard
|
||||
|
||||
def _create_chart(
|
||||
self, name: str, viz_type: str = "bar", datasource_id: int = 1
|
||||
) -> Slice:
|
||||
admin = self.get_user(ADMIN_USERNAME)
|
||||
chart = Slice(
|
||||
slice_name=f"{CHART_PREFIX}{name}",
|
||||
viz_type=viz_type,
|
||||
datasource_id=datasource_id,
|
||||
datasource_type="table",
|
||||
owners=[admin],
|
||||
created_by=admin,
|
||||
changed_by=admin,
|
||||
)
|
||||
db.session.add(chart)
|
||||
db.session.commit()
|
||||
return chart
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------ #
|
||||
def test_create_get_update_delete(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
created = self._create_folder(name="Quarterly", description="Q reports")
|
||||
assert created["name"] == "Quarterly"
|
||||
assert created["folder_type"] == "analytics"
|
||||
assert created["parent_uuid"] is None
|
||||
uuid = created["uuid"]
|
||||
|
||||
# get
|
||||
response = self.client.get(f"/api/v1/folders/{uuid}")
|
||||
assert response.status_code == 200
|
||||
assert response.json["result"]["description"] == "Q reports"
|
||||
|
||||
# update (rename + describe)
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{uuid}", json={"name": "Yearly", "description": None}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["result"]["name"] == "Yearly"
|
||||
assert response.json["result"]["description"] is None
|
||||
|
||||
# delete
|
||||
response = self.client.delete(f"/api/v1/folders/{uuid}")
|
||||
assert response.status_code == 200
|
||||
assert FolderDAO.get_by_uuid(uuid) is None
|
||||
|
||||
def test_get_missing_returns_404(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
response = self.client.get("/api/v1/folders/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_duplicate_sibling_name_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
self._create_folder(name="Dup")
|
||||
response = self.client.post("/api/v1/folders/", json={"name": "Dup"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_with_parent(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
parent = self._create_folder(name="Parent")
|
||||
child = self._create_folder(name="Child", parent_uuid=parent["uuid"])
|
||||
assert child["parent_uuid"] == parent["uuid"]
|
||||
|
||||
def test_create_with_unknown_parent_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
response = self.client.post(
|
||||
"/api/v1/folders/", json={"name": "Orphan", "parent_uuid": "missing"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Move / tree integrity
|
||||
# ------------------------------------------------------------------ #
|
||||
def test_move_into_own_descendant_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
parent = self._create_folder(name="P")
|
||||
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
|
||||
# moving the parent under its own child would create a cycle
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{parent['uuid']}",
|
||||
json={"parent_uuid": child["uuid"]},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_move_to_root(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
parent = self._create_folder(name="P")
|
||||
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{child['uuid']}", json={"parent_uuid": None}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["result"]["parent_uuid"] is None
|
||||
|
||||
def test_delete_reparents_children(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
grandparent = self._create_folder(name="GP")
|
||||
parent = self._create_folder(name="P", parent_uuid=grandparent["uuid"])
|
||||
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
|
||||
|
||||
# delete the middle folder -> child should re-parent to grandparent
|
||||
response = self.client.delete(f"/api/v1/folders/{parent['uuid']}")
|
||||
assert response.status_code == 200
|
||||
|
||||
moved = FolderDAO.get_by_uuid(child["uuid"])
|
||||
assert moved is not None
|
||||
assert moved.parent_id == grandparent["id"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Listing
|
||||
# ------------------------------------------------------------------ #
|
||||
def test_list_filtered_by_type(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
self._create_folder(name="Analytics one")
|
||||
self._create_folder(name="Other one", folder_type="custom")
|
||||
|
||||
response = self.client.get("/api/v1/folders/?folder_type=analytics")
|
||||
assert response.status_code == 200
|
||||
names = {folder["name"] for folder in response.json["result"]}
|
||||
assert "Analytics one" in names
|
||||
assert "Other one" not in names
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Assets
|
||||
# ------------------------------------------------------------------ #
|
||||
def test_root_view_lists_unfoldered_assets(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
dashboard = self._create_dashboard("root")
|
||||
response = self.client.get("/api/v1/folders/assets")
|
||||
assert response.status_code == 200
|
||||
asset_ids = {(a["type"], a["id"]) for a in response.json["result"]}
|
||||
assert ("dashboard", dashboard.id) in asset_ids
|
||||
|
||||
def test_assign_asset_moves_it_out_of_root(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
dashboard = self._create_dashboard("assign")
|
||||
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "dashboard", "id": dashboard.id}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
contents_ids = {(a["type"], a["id"]) for a in response.json["result"]}
|
||||
assert ("dashboard", dashboard.id) in contents_ids
|
||||
|
||||
# no longer unfoldered
|
||||
root = self.client.get("/api/v1/folders/assets").json
|
||||
assert ("dashboard", dashboard.id) not in {
|
||||
(a["type"], a["id"]) for a in root["result"]
|
||||
}
|
||||
|
||||
# contents endpoint agrees
|
||||
contents = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
|
||||
assert ("dashboard", dashboard.id) in {
|
||||
(a["type"], a["id"]) for a in contents["result"]
|
||||
}
|
||||
|
||||
def test_assign_moves_between_folders(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
first = self._create_folder(name="First")
|
||||
second = self._create_folder(name="Second")
|
||||
dashboard = self._create_dashboard("move")
|
||||
|
||||
body = {"assets": [{"type": "dashboard", "id": dashboard.id}]}
|
||||
self.client.put(f"/api/v1/folders/{first['uuid']}/assets", json=body)
|
||||
self.client.put(f"/api/v1/folders/{second['uuid']}/assets", json=body)
|
||||
|
||||
first_contents = self.client.get(f"/api/v1/folders/{first['uuid']}/assets").json
|
||||
second_contents = self.client.get(
|
||||
f"/api/v1/folders/{second['uuid']}/assets"
|
||||
).json
|
||||
assert first_contents["result"] == []
|
||||
assert ("dashboard", dashboard.id) in {
|
||||
(a["type"], a["id"]) for a in second_contents["result"]
|
||||
}
|
||||
|
||||
def test_set_membership_removes_unlisted_assets(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
kept = self._create_dashboard("kept")
|
||||
dropped = self._create_dashboard("dropped")
|
||||
|
||||
# Put both in the folder.
|
||||
self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={
|
||||
"assets": [
|
||||
{"type": "dashboard", "id": kept.id},
|
||||
{"type": "dashboard", "id": dropped.id},
|
||||
]
|
||||
},
|
||||
)
|
||||
# Re-set membership to only ``kept`` -> ``dropped`` moves back to root.
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "dashboard", "id": kept.id}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
folder_ids = {(a["type"], a["id"]) for a in response.json["result"]}
|
||||
assert ("dashboard", kept.id) in folder_ids
|
||||
assert ("dashboard", dropped.id) not in folder_ids
|
||||
|
||||
root_ids = {
|
||||
(a["type"], a["id"])
|
||||
for a in self.client.get("/api/v1/folders/assets").json["result"]
|
||||
}
|
||||
assert ("dashboard", dropped.id) in root_ids
|
||||
|
||||
# An empty list empties the folder entirely.
|
||||
self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets", json={"assets": []}
|
||||
)
|
||||
contents = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
|
||||
assert all(a["type"] == "folder" for a in contents["result"])
|
||||
|
||||
def test_assign_unknown_asset_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "dashboard", "id": 999999}]},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_assign_unknown_asset_type_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "report", "id": 1}]},
|
||||
)
|
||||
# unknown type -> schema validation error
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_assign_disallowed_asset_type_rejected(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
response = self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "dataset", "id": 1}]},
|
||||
)
|
||||
# valid type, but datasets do not belong in an analytics folder
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_delete_folder_unfolders_assets(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Bucket")
|
||||
dashboard = self._create_dashboard("unfolder")
|
||||
self.client.put(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets",
|
||||
json={"assets": [{"type": "dashboard", "id": dashboard.id}]},
|
||||
)
|
||||
|
||||
self.client.delete(f"/api/v1/folders/{folder['uuid']}")
|
||||
|
||||
# the dashboard survives and is unfoldered again
|
||||
assert db.session.get(Dashboard, dashboard.id) is not None
|
||||
root = self.client.get("/api/v1/folders/assets").json
|
||||
assert ("dashboard", dashboard.id) in {
|
||||
(a["type"], a["id"]) for a in root["result"]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Filtering & pagination (scoped to a folder for deterministic counts)
|
||||
# ------------------------------------------------------------------ #
|
||||
def _assign(self, folder_uuid: str, assets: list[dict[str, Any]]) -> None:
|
||||
resp = self.client.put(
|
||||
f"/api/v1/folders/{folder_uuid}/assets", json={"assets": assets}
|
||||
)
|
||||
assert resp.status_code == 200, resp.json
|
||||
|
||||
def test_contents_response_shape(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Shape")
|
||||
resp = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
|
||||
for key in ("folder", "result", "count", "page", "page_size"):
|
||||
assert key in resp
|
||||
|
||||
def test_contents_search_by_name(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Box")
|
||||
alpha = self._create_dashboard("Alpha")
|
||||
beta = self._create_dashboard("Beta")
|
||||
self._assign(
|
||||
folder["uuid"],
|
||||
[
|
||||
{"type": "dashboard", "id": alpha.id},
|
||||
{"type": "dashboard", "id": beta.id},
|
||||
],
|
||||
)
|
||||
resp = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?search={DASH_PREFIX}Alpha"
|
||||
).json
|
||||
names = {r["name"] for r in resp["result"]}
|
||||
assert any("Alpha" in n for n in names)
|
||||
assert not any("Beta" in n for n in names)
|
||||
|
||||
def test_contents_type_filter(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Mix")
|
||||
self._create_folder(name="Sub", parent_uuid=folder["uuid"])
|
||||
dash = self._create_dashboard("d")
|
||||
self._assign(folder["uuid"], [{"type": "dashboard", "id": dash.id}])
|
||||
resp = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?types=dashboard"
|
||||
).json
|
||||
assert {r["type"] for r in resp["result"]} == {"dashboard"}
|
||||
|
||||
def test_contents_pagination(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Paged")
|
||||
dashboards = [self._create_dashboard(f"p{i}") for i in range(3)]
|
||||
self._assign(
|
||||
folder["uuid"],
|
||||
[{"type": "dashboard", "id": d.id} for d in dashboards],
|
||||
)
|
||||
page0 = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?page=0&page_size=2"
|
||||
).json
|
||||
assert page0["count"] == 3
|
||||
assert page0["page_size"] == 2
|
||||
assert len(page0["result"]) == 2
|
||||
page1 = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?page=1&page_size=2"
|
||||
).json
|
||||
assert len(page1["result"]) == 1
|
||||
|
||||
def test_contents_viz_type_filter_and_columns(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Viz")
|
||||
bar = self._create_chart("bar", viz_type="bar")
|
||||
area = self._create_chart("area", viz_type="area")
|
||||
self._assign(
|
||||
folder["uuid"],
|
||||
[{"type": "chart", "id": bar.id}, {"type": "chart", "id": area.id}],
|
||||
)
|
||||
resp = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?viz_types=area"
|
||||
).json
|
||||
rows = {(r["type"], r["id"]): r for r in resp["result"]}
|
||||
assert ("chart", area.id) in rows
|
||||
assert ("chart", bar.id) not in rows
|
||||
# column data is serialized on asset rows
|
||||
row = rows[("chart", area.id)]
|
||||
assert row["viz_type"] == "area"
|
||||
assert any(o["id"] for o in row["owners"])
|
||||
|
||||
def test_asset_only_filter_excludes_folders(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
folder = self._create_folder(name="Excl")
|
||||
self._create_folder(name="SubExcl", parent_uuid=folder["uuid"])
|
||||
chart = self._create_chart("bar2", viz_type="bar")
|
||||
self._assign(folder["uuid"], [{"type": "chart", "id": chart.id}])
|
||||
resp = self.client.get(
|
||||
f"/api/v1/folders/{folder['uuid']}/assets?viz_types=bar"
|
||||
).json
|
||||
types = {r["type"] for r in resp["result"]}
|
||||
assert "folder" not in types
|
||||
assert ("chart", chart.id) in {(r["type"], r["id"]) for r in resp["result"]}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Auth
|
||||
# ------------------------------------------------------------------ #
|
||||
def test_requires_authentication(self):
|
||||
response = self.client.get("/api/v1/folders/")
|
||||
assert response.status_code == 401
|
||||
16
tests/unit_tests/folders/__init__.py
Normal file
16
tests/unit_tests/folders/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
91
tests/unit_tests/folders/test_schemas.py
Normal file
91
tests/unit_tests/folders/test_schemas.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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 pytest
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.folders.schemas import (
|
||||
FolderAssetsPutSchema,
|
||||
FolderPostSchema,
|
||||
FolderPutSchema,
|
||||
)
|
||||
|
||||
|
||||
def test_post_schema_applies_defaults() -> None:
|
||||
data = FolderPostSchema().load({"name": "Marketing"})
|
||||
assert data == {
|
||||
"name": "Marketing",
|
||||
"description": None,
|
||||
"parent_uuid": None,
|
||||
"folder_type": "analytics",
|
||||
"is_private": False,
|
||||
}
|
||||
|
||||
|
||||
def test_post_schema_requires_name() -> None:
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
FolderPostSchema().load({})
|
||||
assert "name" in excinfo.value.messages
|
||||
|
||||
|
||||
def test_post_schema_rejects_blank_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
FolderPostSchema().load({"name": ""})
|
||||
|
||||
|
||||
def test_post_schema_rejects_overlong_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
FolderPostSchema().load({"name": "x" * 251})
|
||||
|
||||
|
||||
def test_put_schema_is_partial() -> None:
|
||||
# Only provided keys appear, so the DAO leaves other fields untouched.
|
||||
assert FolderPutSchema().load({}) == {}
|
||||
assert FolderPutSchema().load({"name": "Renamed"}) == {"name": "Renamed"}
|
||||
|
||||
|
||||
def test_put_schema_allows_clearing_parent_and_description() -> None:
|
||||
data = FolderPutSchema().load({"parent_uuid": None, "description": None})
|
||||
assert data == {"parent_uuid": None, "description": None}
|
||||
|
||||
|
||||
def test_assets_schema_accepts_known_types() -> None:
|
||||
data = FolderAssetsPutSchema().load(
|
||||
{"assets": [{"type": "dashboard", "id": 1}, {"type": "chart", "id": 2}]}
|
||||
)
|
||||
assert data["assets"] == [
|
||||
{"type": "dashboard", "id": 1},
|
||||
{"type": "chart", "id": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_assets_schema_rejects_unknown_type() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
FolderAssetsPutSchema().load({"assets": [{"type": "report", "id": 1}]})
|
||||
|
||||
|
||||
def test_assets_schema_allows_empty_list() -> None:
|
||||
# Membership-set semantics: an empty list empties the folder.
|
||||
assert FolderAssetsPutSchema().load({"assets": []}) == {"assets": []}
|
||||
|
||||
|
||||
def test_assets_schema_defaults_missing_to_empty() -> None:
|
||||
assert FolderAssetsPutSchema().load({}) == {"assets": []}
|
||||
|
||||
|
||||
def test_assets_schema_requires_id() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
FolderAssetsPutSchema().load({"assets": [{"type": "dashboard"}]})
|
||||
117
tests/unit_tests/folders/test_serialization.py
Normal file
117
tests/unit_tests/folders/test_serialization.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from superset.folders.api import serialize_asset, serialize_assets, serialize_folder
|
||||
|
||||
|
||||
def _user(uid: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=uid, first_name="Ada", last_name="Lovelace")
|
||||
|
||||
|
||||
# Returns ``Any`` so the lightweight stubs satisfy the ``Folder``-typed helpers.
|
||||
def _folder(**kwargs: Any) -> Any:
|
||||
defaults = {
|
||||
"id": 1,
|
||||
"uuid": UUID("00000000-0000-0000-0000-000000000001"),
|
||||
"name": "Folder",
|
||||
"description": None,
|
||||
"parent": None,
|
||||
"folder_type": "analytics",
|
||||
"is_private": False,
|
||||
"children": [],
|
||||
"objects": [],
|
||||
"created_on": datetime(2026, 1, 1),
|
||||
"changed_on": datetime(2026, 1, 2),
|
||||
"created_by": _user(1),
|
||||
"changed_by": _user(2),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_serialize_folder_at_root() -> None:
|
||||
result = serialize_folder(_folder(children=[1, 2], objects=[1]))
|
||||
assert result["uuid"] == "00000000-0000-0000-0000-000000000001"
|
||||
assert result["parent_uuid"] is None
|
||||
assert result["children_count"] == 2
|
||||
assert result["asset_count"] == 1
|
||||
assert result["created_by"] == {
|
||||
"id": 1,
|
||||
"first_name": "Ada",
|
||||
"last_name": "Lovelace",
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_folder_includes_parent_uuid() -> None:
|
||||
parent = SimpleNamespace(uuid=UUID("00000000-0000-0000-0000-0000000000ff"))
|
||||
result = serialize_folder(_folder(parent=parent))
|
||||
assert result["parent_uuid"] == "00000000-0000-0000-0000-0000000000ff"
|
||||
|
||||
|
||||
def test_serialize_folder_without_audit_user() -> None:
|
||||
result = serialize_folder(_folder(created_by=None, changed_by=None))
|
||||
assert result["created_by"] is None
|
||||
assert result["changed_by"] is None
|
||||
|
||||
|
||||
def test_serialize_asset_uses_type_specific_title() -> None:
|
||||
dashboard = SimpleNamespace(
|
||||
id=7,
|
||||
uuid=UUID("00000000-0000-0000-0000-00000000000a"),
|
||||
dashboard_title="Sales",
|
||||
url="/superset/dashboard/7/",
|
||||
changed_on=datetime(2026, 1, 1),
|
||||
)
|
||||
chart = SimpleNamespace(
|
||||
id=9,
|
||||
uuid=None,
|
||||
slice_name="Revenue",
|
||||
url="/explore/?slice_id=9",
|
||||
changed_on=datetime(2026, 1, 1),
|
||||
)
|
||||
assert serialize_asset("dashboard", dashboard)["name"] == "Sales"
|
||||
assert serialize_asset("dashboard", dashboard)["type"] == "dashboard"
|
||||
assert serialize_asset("chart", chart)["name"] == "Revenue"
|
||||
assert serialize_asset("chart", chart)["uuid"] is None
|
||||
|
||||
|
||||
def test_serialize_assets_sorts_by_changed_on_desc() -> None:
|
||||
older = SimpleNamespace(
|
||||
id=1,
|
||||
uuid=None,
|
||||
dashboard_title="Old",
|
||||
url=None,
|
||||
changed_on=datetime(2025, 1, 1),
|
||||
)
|
||||
newer = SimpleNamespace(
|
||||
id=2,
|
||||
uuid=None,
|
||||
dashboard_title="New",
|
||||
url=None,
|
||||
changed_on=datetime(2026, 1, 1),
|
||||
)
|
||||
undated = SimpleNamespace(
|
||||
id=3, uuid=None, dashboard_title="None", url=None, changed_on=None
|
||||
)
|
||||
result = serialize_assets(
|
||||
[("dashboard", older), ("dashboard", undated), ("dashboard", newer)]
|
||||
)
|
||||
assert [item["name"] for item in result] == ["New", "Old", "None"]
|
||||
Reference in New Issue
Block a user