Compare commits

...

13 Commits

Author SHA1 Message Date
alexandrusoare
b2529d7c09 pushing changes 2026-07-07 12:48:11 +03:00
alexandrusoare
5f9a68f956 a lot of changes 2026-07-06 12:41:57 +03:00
alexandrusoare
33f60e3b16 Merge branch 'master' of https://github.com/apache/superset into alexandrusoare/feat/folders-testing-branch 2026-06-23 11:30:17 +03:00
alexandrusoare
9ec346bb23 a lot of fixes 2026-06-19 14:55:38 +03:00
alexandrusoare
538eabd6df Merge branch 'master' of https://github.com/apache/superset into alexandrusoare/feat/folders-testing-branch 2026-06-18 12:35:58 +03:00
alexandrusoare
9a17b400da Merge branch 'master' of https://github.com/apache/superset into alexandrusoare/feat/folders-testing-branch 2026-06-16 12:46:37 +03:00
alexandrusoare
6e1d118773 Merge branch 'master' of https://github.com/apache/superset into alexandrusoare/feat/folders-testing-branch 2026-06-15 17:04:16 +03:00
alexandrusoare
0810116646 feat(folders): add URL routing, per-folder auth, permission modal improvements, and bulk action controls 2026-06-11 16:13:13 +03:00
alexandrusoare
7c474a05d9 small change 2026-06-10 15:12:38 +03:00
alexandrusoare
b1bd7d8be2 major improvements 2026-06-10 14:49:25 +03:00
alexandrusoare
ae49a80cbe standardizing the permission manage modal 2026-06-09 17:45:38 +03:00
alexandrusoare
dc49f1145a Merge remote-tracking branch 'superset-private/master' into alexandrusoare/feat/folders-testing-branch 2026-06-09 16:22:37 +03:00
Alexandru Soare
d5333ac47b feat(folders): Create datamodel for folder system (#818)
* feat(folders): create datamodel

* feat(folders): create datamodel

* feat(permissions): Adding folder permission logic (#831)

* feat(permission): adding folder permissions

* small refactor

* feat(folders): add CRUD API endpoints (#823)

squashing branches

* fix issues from comments

---------

Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2026-06-09 16:10:04 +03:00
49 changed files with 9635 additions and 44 deletions

View 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.
*/
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.',
},
},
};

View File

@@ -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');
});

View File

@@ -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}
/>
);
}

View File

@@ -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.',
},
},
};

View File

@@ -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]);
});

View File

@@ -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}
/>
);
}

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
),

View File

@@ -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,

View File

@@ -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();
};

View File

@@ -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,
}));
}

View File

@@ -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 {

View File

@@ -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 => ({

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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>
}
/>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
};

View 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.

View 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)

View 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)

View 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()

View 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",
)

View 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)

View File

@@ -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
View 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()

View 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
]

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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)

View File

@@ -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)

View File

@@ -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
View 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()

View 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.

View 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

View 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.

View 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"}]})

View 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"]