You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/09/11 11:19:21 UTC
(superset) 01/01: latest state
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a commit to branch alexandrusoare/feat/folders-testing-phase2-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
commit b892bba091febe756f3c8bb941f657fb0b2fa485
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Fri Sep 11 14:18:53 2026 +0300
latest state
---
.../Folders/FolderBreadcrumb.stories.tsx | 83 +
.../components/Folders/FolderBreadcrumb.test.tsx | 87 +
.../src/components/Folders/FolderBreadcrumb.tsx | 99 ++
.../src/components/Folders/FolderList.stories.tsx | 292 ++++
.../src/components/Folders/FolderList.test.tsx | 200 +++
.../src/components/Folders/FolderList.tsx | 209 +++
.../src/components/Folders/assetIcons.tsx | 35 +
.../src/components/Folders/index.ts | 30 +
.../src/components/Folders/types.ts | 106 ++
.../src/pages/AnalyticsList/CreateFolderModal.tsx | 141 ++
.../src/pages/AnalyticsList/DashboardCharts.tsx | 216 +++
.../src/pages/AnalyticsList/DeleteFolderModal.tsx | 127 ++
.../src/pages/AnalyticsList/EditFolderModal.tsx | 153 ++
.../pages/AnalyticsList/FolderPermissionsModal.tsx | 438 +++++
.../src/pages/AnalyticsList/FolderSelector.tsx | 170 ++
.../src/pages/AnalyticsList/RenameFolderModal.tsx | 110 ++
.../src/pages/AnalyticsList/TransferModal.tsx | 1026 +++++++++++
.../src/pages/AnalyticsList/index.tsx | 1783 ++++++++++++++++++++
.../src/pages/AnalyticsList/styles.ts | 25 +
superset-frontend/src/pages/AnalyticsList/types.ts | 62 +
.../pages/components/FolderBreadcrumb.stories.tsx | 83 +
.../src/pages/components/FolderBreadcrumb.test.tsx | 87 +
.../src/pages/components/FolderBreadcrumb.tsx | 99 ++
.../src/pages/components/FolderList.stories.tsx | 298 ++++
.../src/pages/components/FolderList.test.tsx | 200 +++
.../src/pages/components/FolderList.tsx | 209 +++
.../src/pages/components/assetIcons.tsx | 35 +
superset-frontend/src/pages/components/index.ts | 30 +
superset-frontend/src/pages/components/types.ts | 106 ++
superset-frontend/src/views/routes.tsx | 12 +
superset/commands/folder/__init__.py | 16 +
superset/commands/folder/assets.py | 135 ++
superset/commands/folder/create.py | 102 ++
superset/commands/folder/delete.py | 65 +
superset/commands/folder/exceptions.py | 125 ++
superset/commands/folder/update.py | 151 ++
superset/config.py | 7 +
superset/daos/folder.py | 1030 +++++++++++
superset/daos/folder_permissions.py | 457 +++++
superset/folders/__init__.py | 16 +
superset/folders/api.py | 1558 +++++++++++++++++
superset/folders/constants.py | 83 +
superset/folders/hooks.py | 312 ++++
superset/folders/models.py | 248 +++
superset/folders/schemas.py | 242 +++
superset/folders/utils.py | 52 +
superset/initialization/__init__.py | 49 +-
...6-05-27_16-01_4567cf3d03cc_add_folder_tables.py | 332 ++++
superset/security/manager.py | 29 +
superset/views/folders.py | 47 +
tests/integration_tests/folders/__init__.py | 16 +
tests/integration_tests/folders/api_tests.py | 442 +++++
tests/unit_tests/folders/__init__.py | 16 +
tests/unit_tests/folders/test_schemas.py | 91 +
tests/unit_tests/folders/test_serialization.py | 117 ++
55 files changed, 12273 insertions(+), 16 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx
new file mode 100644
index 00000000000..3b9c4f1e578
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx
@@ -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.',
+ },
+ },
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx
new file mode 100644
index 00000000000..3d61dbf0042
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx
@@ -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');
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx
new file mode 100644
index 00000000000..48aeb52f48c
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx
@@ -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}
+ />
+ );
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx
new file mode 100644
index 00000000000..083dfa145ce
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx
@@ -0,0 +1,292 @@
+/**
+ * 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.',
+ },
+ },
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx
new file mode 100644
index 00000000000..f681016fa63
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx
@@ -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]);
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx
new file mode 100644
index 00000000000..3f73f515fe4
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx
@@ -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}
+ />
+ );
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx
new file mode 100644
index 00000000000..891a4e5f8be
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx
@@ -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,
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts b/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts
new file mode 100644
index 00000000000..1b8e1bab787
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts
@@ -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';
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts b/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts
new file mode 100644
index 00000000000..09b85e91e2e
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts
@@ -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;
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
new file mode 100644
index 00000000000..90725fe4046
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
@@ -0,0 +1,141 @@
+/**
+ * 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 { SupersetClient, getClientErrorObject } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Checkbox, Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+import { ModalContent } from './styles';
+
+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;
+}
+
+export default function CreateFolderModal({
+ show,
+ parentFolderUuid,
+ onHide,
+ onSuccess,
+ addDangerToast,
+ addSuccessToast,
+}: CreateFolderModalProps) {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [nameError, setNameError] = useState('');
+ const [isPrivate, setIsPrivate] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (show) {
+ setName('');
+ setDescription('');
+ setNameError('');
+ }
+ }, [show]);
+
+ const handleSave = useCallback(async () => {
+ if (!name.trim()) {
+ setNameError(t('Folder name is required'));
+ return;
+ }
+ setNameError('');
+ setSaving(true);
+ try {
+ await SupersetClient.post({
+ endpoint: '/api/v1/folders/',
+ jsonPayload: {
+ name: name.trim(),
+ description: description.trim() || null,
+ folder_type: 'analytics',
+ ...(parentFolderUuid ? { parent_uuid: parentFolderUuid } : {}),
+ ...(isPrivate ? { is_private: true } : {}),
+ },
+ });
+ addSuccessToast(t('Folder "%s" created', name.trim()));
+ onSuccess();
+ onHide();
+ } catch (err: any) {
+ const { error } = await getClientErrorObject(err);
+ const isDuplicate = error?.toLowerCase().includes('already exists');
+ addDangerToast(isDuplicate ? error : t('Error creating folder'));
+ } finally {
+ setSaving(false);
+ }
+ }, [
+ name,
+ description,
+ 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>
+ <ModalFormField label={t('Name')} required error={nameError}>
+ <Input
+ placeholder={t('Folder name')}
+ value={name}
+ onChange={e => {
+ setName(e.target.value);
+ if (nameError) setNameError('');
+ }}
+ onPressEnter={handleSave}
+ status={nameError ? 'error' : undefined}
+ autoFocus
+ />
+ </ModalFormField>
+ <ModalFormField label={t('Description')}>
+ <Input.TextArea
+ placeholder={t('Optional description')}
+ value={description}
+ onChange={e => setDescription(e.target.value)}
+ rows={3}
+ />
+ </ModalFormField>
+ {!parentFolderUuid && (
+ <Checkbox
+ checked={isPrivate}
+ onChange={e => setIsPrivate(e.target.checked)}
+ >
+ {t('Private folder')}
+ </Checkbox>
+ )}
+ </ModalContent>
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx b/superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx
new file mode 100644
index 00000000000..7849117d231
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx
@@ -0,0 +1,216 @@
+/**
+ * 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 {
+ isFeatureEnabled,
+ FeatureFlag,
+ getChartMetadataRegistry,
+} from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { EmptyState, Skeleton } from '@superset-ui/core/components';
+import { ModifiedInfo, TagsList, TagType } from 'src/components';
+import { SubjectPile } from 'src/features/subjects/SubjectPile';
+import { TagTypeEnum } from 'src/components/Tag/TagType';
+import { Icons } from '@superset-ui/core/components/Icons';
+
+import type { ContentItem } from './types';
+
+const chartRegistry = getChartMetadataRegistry();
+
+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.
+ // Re-read on resize so the grid stays aligned when the window changes size.
+ useEffect(() => {
+ const readWidths = () => {
+ 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'),
+ );
+ const dataHeaders = ths.filter(th => th.offsetWidth > 40);
+ if (dataHeaders.length > 0) {
+ setColWidths(dataHeaders.map(th => `${th.offsetWidth}px`).join(' '));
+ }
+ };
+ readWidths();
+ window.addEventListener('resize', readWidths);
+ return () => window.removeEventListener('resize', readWidths);
+ }, [charts.length]);
+
+ if (loading) {
+ return <Skeleton active />;
+ }
+
+ if (charts.length === 0) {
+ return (
+ <EmptyState
+ size="small"
+ description={t('This dashboard has no charts')}
+ />
+ );
+ }
+
+ const showTags = isFeatureEnabled(FeatureFlag.TaggingSystem);
+
+ return (
+ <Wrapper ref={wrapperRef}>
+ <SubHeader>
+ <Icons.LineChartOutlined 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.LineChartOutlined
+ iconSize="m"
+ css={{ color: theme.colorErrorBorderHover, 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>
+ <SubjectPile subjects={chart.editors || []} />
+ </Cell>
+ <Cell>
+ <SubjectPile subjects={chart.viewers || []} />
+ </Cell>
+ <Cell>
+ {chart.changed_on_humanized ? (
+ <ModifiedInfo
+ date={chart.changed_on_humanized}
+ user={chart.changed_by ?? undefined}
+ />
+ ) : (
+ '-'
+ )}
+ </Cell>
+ </Row>
+ ))}
+ </Wrapper>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
new file mode 100644
index 00000000000..525b14a3181
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
@@ -0,0 +1,127 @@
+/**
+ * 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 { SupersetClient } from '@superset-ui/core';
+import { useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+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);
+
+ // Reset checkbox when folder changes (e.g. queue processing)
+ useEffect(() => {
+ setDeleteItems(false);
+ }, [folder.uuid]);
+
+ 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>
+ }
+ />
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx
new file mode 100644
index 00000000000..5aadf2c0903
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx
@@ -0,0 +1,153 @@
+/**
+ * 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 { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+import { ModalContent } from './styles';
+
+interface RenameFolderModalProps {
+ folderUuid: string;
+ currentName: string;
+ currentDescription?: string | null;
+ show: boolean;
+ onHide: () => void;
+ onSuccess: () => void;
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+}
+
+export default function RenameFolderModal({
+ folderUuid,
+ currentName,
+ currentDescription = '',
+ show,
+ onHide,
+ onSuccess,
+ addDangerToast,
+ addSuccessToast,
+}: RenameFolderModalProps) {
+ const [name, setName] = useState(currentName);
+ const [description, setDescription] = useState(currentDescription || '');
+ const [nameError, setNameError] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (show) {
+ setName(currentName);
+ setDescription(currentDescription || '');
+ setNameError('');
+ }
+ }, [show, currentName, currentDescription]);
+
+ const hasChanges =
+ name.trim() !== currentName ||
+ (description.trim() || null) !== (currentDescription || null);
+
+ const handleSave = useCallback(async () => {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ setNameError(t('Folder name is required'));
+ return;
+ }
+ if (!hasChanges) {
+ onHide();
+ return;
+ }
+ setNameError('');
+ setSaving(true);
+ try {
+ await SupersetClient.put({
+ endpoint: `/api/v1/folders/${folderUuid}`,
+ jsonPayload: {
+ name: trimmed,
+ description: description.trim() || null,
+ },
+ });
+ addSuccessToast(t('Folder updated'));
+ onSuccess();
+ onHide();
+ } catch (err: any) {
+ const message =
+ err?.json?.message?.name?.[0] ||
+ err?.json?.message ||
+ t('Error updating folder');
+ if (
+ typeof message === 'string' &&
+ message.toLowerCase().includes('already exists')
+ ) {
+ setNameError(message);
+ } else {
+ addDangerToast(
+ typeof message === 'string' ? message : t('Error updating folder'),
+ );
+ }
+ } finally {
+ setSaving(false);
+ }
+ }, [
+ name,
+ description,
+ hasChanges,
+ folderUuid,
+ addSuccessToast,
+ addDangerToast,
+ onSuccess,
+ onHide,
+ ]);
+
+ return (
+ <StandardModal
+ title={t('Edit folder')}
+ show={show}
+ onHide={onHide}
+ onSave={handleSave}
+ saveText={t('Save')}
+ saveDisabled={!name.trim() || !hasChanges}
+ saveLoading={saving}
+ >
+ <ModalContent>
+ <ModalFormField label={t('Name')} required error={nameError}>
+ <Input
+ placeholder={t('Folder name')}
+ value={name}
+ onChange={e => {
+ setName(e.target.value);
+ if (nameError) setNameError('');
+ }}
+ onPressEnter={handleSave}
+ status={nameError ? 'error' : undefined}
+ autoFocus
+ />
+ </ModalFormField>
+ <ModalFormField label={t('Description')}>
+ <Input.TextArea
+ placeholder={t('Optional description')}
+ value={description}
+ onChange={e => setDescription(e.target.value)}
+ rows={3}
+ />
+ </ModalFormField>
+ </ModalContent>
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
new file mode 100644
index 00000000000..06f56f62650
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
@@ -0,0 +1,438 @@
+/**
+ * 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, useRef, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Alert } from '@apache-superset/core/components';
+import {
+ AsyncSelect,
+ FormLabel,
+ Input,
+ Select,
+ Table,
+ TableSize,
+ type ColumnsType,
+ type SelectOptionsTypePage,
+ Tooltip,
+ Typography,
+ type SelectValue,
+} from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { Icons } from '@superset-ui/core/components/Icons';
+
+type Permission = 'editor' | 'viewer' | 'admin';
+
+interface Subject {
+ user_id: number;
+ permission: Permission;
+ email?: string;
+ is_admin?: boolean;
+}
+
+interface LocalSubject {
+ key: string;
+ user_id: number;
+ permission: Permission;
+ label: string;
+ isCurrentUser: boolean;
+ isAdmin: 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 StyledFormLabel = styled(FormLabel)`
+ ${({ theme }) => css`
+ margin-bottom: ${theme.sizeUnit * 0.5}px;
+ font-size: ${theme.fontSizeSM}px;
+ `}
+`;
+
+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;
+ `}
+`;
+
+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 adminIdsRef = useRef<Set<number>>(new Set());
+
+ 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.is_admin ? 'admin' : s.permission,
+ label: getUserLabel(s),
+ isCurrentUser: s.user_id === currentUserId,
+ isAdmin: !!s.is_admin,
+ }));
+ 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;
+ const isAdmin = adminIdsRef.current.has(userId);
+ setLocalSubjects(prev => {
+ if (prev.some(s => s.user_id === userId)) return prev;
+ return [
+ ...prev,
+ {
+ key: String(userId),
+ user_id: userId,
+ permission: isAdmin
+ ? ('admin' as Permission)
+ : ('viewer' as Permission),
+ label: String(item.label),
+ isCurrentUser: userId === currentUserId,
+ isAdmin,
+ },
+ ];
+ });
+ },
+ [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 || [];
+ results.forEach((u: { id: number; is_admin?: boolean }) => {
+ if (u.is_admin) adminIdsRef.current.add(u.id);
+ });
+ return {
+ data: results.map(
+ (u: { id: number; email: string; is_admin?: boolean }) => ({
+ 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) =>
+ record.isAdmin ? (
+ <Tooltip
+ title={t(
+ 'This user is an admin and has full access to all folders',
+ )}
+ >
+ <Typography.Text type="secondary">{t('Admin')}</Typography.Text>
+ </Tooltip>
+ ) : record.isCurrentUser ? (
+ <Tooltip
+ title={t("You can't change your own permission")}
+ >
+ <Typography.Text type="secondary">
+ {PERMISSION_OPTIONS.find(o => o.value === permission)?.label ??
+ permission}
+ </Typography.Text>
+ </Tooltip>
+ ) : (
+ <Select
+ ariaLabel={t('Permission')}
+ options={PERMISSION_OPTIONS}
+ value={permission}
+ 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 || record.isAdmin ? 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>
+ <StyledFormLabel>{t('Add user')}</StyledFormLabel>
+ <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>
+ <StyledFormLabel>
+ {t('Members')} ({localSubjects.length})
+ </StyledFormLabel>
+ <Input
+ placeholder={t('Search members…')}
+ value={memberSearch}
+ onChange={e => setMemberSearch(e.target.value)}
+ allowClear
+ css={{ marginBottom: theme.sizeUnit * 2 }}
+ />
+ <Table<LocalSubject>
+ key={memberSearch}
+ data={filtered}
+ columns={columns}
+ usePagination
+ defaultPageSize={10}
+ size={TableSize.Small}
+ height={300}
+ />
+ </div>
+ </ModalContent>
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx b/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx
new file mode 100644
index 00000000000..3eaf0f6cc2f
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx
@@ -0,0 +1,170 @@
+/**
+ * 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 { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { TreeSelect } from '@superset-ui/core/components/TreeSelect';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+interface FolderInfo {
+ uuid: string;
+ name: string;
+ parent_uuid: string | null;
+ user_permission?: string | null;
+ is_only_me?: boolean;
+ is_private?: boolean;
+}
+
+export interface FolderSelectorProps {
+ assetId: number;
+ assetType: 'chart' | 'dashboard';
+ accessorCount?: number;
+ onChange?: (folderUuid: string | null) => void;
+ onValidationError?: (error: string | null) => void;
+}
+
+const ROOT_VALUE = '__root__';
+
+export default function FolderSelector({
+ assetId,
+ assetType,
+ accessorCount = 0,
+ onChange,
+ onValidationError,
+}: FolderSelectorProps) {
+ const [folders, setFolders] = useState<FolderInfo[]>([]);
+ const [selectedValue, setSelectedValue] = useState<string | null>(null);
+ const [initialValue, setInitialValue] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ // Fetch folder list + current asset location on mount
+ useEffect(() => {
+ setLoading(true);
+ Promise.all([
+ SupersetClient.get({
+ endpoint: '/api/v1/folders/?folder_type=analytics',
+ }),
+ SupersetClient.get({
+ endpoint: `/api/v1/folders/asset-location?type=${assetType}&id=${assetId}`,
+ }),
+ ])
+ .then(([foldersRes, locationRes]) => {
+ const allFolders = (foldersRes.json.result as FolderInfo[]) || [];
+ setFolders(allFolders.filter(f => f.user_permission === 'editor'));
+
+ const location = locationRes.json.result;
+ const currentUuid = location?.folder_uuid ?? ROOT_VALUE;
+ setSelectedValue(currentUuid);
+ setInitialValue(currentUuid);
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [assetId, assetType]);
+
+ // Build tree data for TreeSelect
+ const treeData = useMemo(() => {
+ type TreeNode = {
+ value: string;
+ title: string;
+ children: TreeNode[];
+ };
+
+ const nodeMap = new Map<string, TreeNode>();
+ for (const f of folders) {
+ nodeMap.set(f.uuid, {
+ value: f.uuid,
+ title: f.name,
+ children: [],
+ });
+ }
+ const roots: TreeNode[] = [];
+ for (const f of folders) {
+ const node = nodeMap.get(f.uuid)!;
+ if (f.parent_uuid && nodeMap.has(f.parent_uuid)) {
+ nodeMap.get(f.parent_uuid)!.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ return [
+ { value: ROOT_VALUE, title: t('Analytics (root)'), children: [] },
+ ...roots,
+ ];
+ }, [folders]);
+
+ // Only Me validation: item is in Only Me folder AND has multiple accessors
+ const validateOnlyMe = useCallback(
+ (folderValue: string, count: number) => {
+ const folder = folders.find(f => f.uuid === folderValue);
+ if (folder?.is_only_me && count > 1) {
+ const errMsg = t(
+ 'Move this item out of your private folder before sharing',
+ );
+ onValidationError?.(errMsg);
+ } else {
+ onValidationError?.(null);
+ }
+ },
+ [folders, onValidationError],
+ );
+
+ // Re-validate when accessorCount changes (user adds/removes people)
+ useEffect(() => {
+ if (selectedValue) {
+ validateOnlyMe(selectedValue, accessorCount);
+ }
+ }, [accessorCount, selectedValue, validateOnlyMe]);
+
+ const handleChange = useCallback(
+ (value: string) => {
+ setSelectedValue(value);
+ const folderUuid = value === ROOT_VALUE ? null : value;
+
+ validateOnlyMe(value, accessorCount);
+
+ onChange?.(folderUuid);
+ },
+ [accessorCount, onChange, validateOnlyMe],
+ );
+
+ const hasChanged = selectedValue !== initialValue;
+
+ return (
+ <ModalFormField
+ label={t('Folder')}
+ helperText={
+ hasChanged ? t('The item will be moved when you save.') : undefined
+ }
+ >
+ <TreeSelect
+ value={selectedValue ?? ROOT_VALUE}
+ treeData={treeData}
+ onChange={handleChange}
+ treeDefaultExpandAll
+ showSearch
+ filterTreeNode={(input: string, node: any) =>
+ (node?.title as string)?.toLowerCase().includes(input.toLowerCase())
+ }
+ loading={loading}
+ style={{ width: '100%' }}
+ />
+ </ModalFormField>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
new file mode 100644
index 00000000000..9038a63b3bc
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
@@ -0,0 +1,110 @@
+/**
+ * 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 { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+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;
+}
+
+import { ModalContent } from './styles';
+
+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>
+ <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
+ />
+ </ModalContent>
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
new file mode 100644
index 00000000000..843643074f3
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
@@ -0,0 +1,1026 @@
+/**
+ * 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,
+ type UIEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { SupersetClient, getNumberFormatter } from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import rison from 'rison';
+import { Alert } from '@apache-superset/core/components';
+import {
+ Button,
+ Checkbox,
+ Constants,
+ Flex,
+ FormLabel,
+ Input,
+ List,
+ Skeleton,
+} 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 { useConfirmModal } from 'src/hooks/useConfirmModal';
+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;
+ is_private?: boolean;
+ user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+}
+
+interface TransferModalProps {
+ currentFolderUuid: string | null;
+ currentFolderName: string;
+ currentFolderIsPrivate?: boolean;
+ 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;
+ .superset-button {
+ font-family: inherit;
+ font-weight: normal;
+ margin-left: 0 !important;
+ }
+ .superset-button:first-of-type {
+ padding-right: 0 !important;
+ }
+ .superset-button:last-of-type {
+ padding-left: 0 !important;
+ }
+ `}
+`;
+
+const StyledFormLabel = styled(FormLabel)`
+ ${({ theme }) => css`
+ margin-bottom: ${theme.sizeUnit * 0.5}px;
+ font-size: ${theme.fontSizeSM}px;
+ `}
+`;
+
+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,
+ };
+}
+
+const smartFormatter = getNumberFormatter('SMART_NUMBER');
+const formatCount = (n: number) => smartFormatter(n);
+
+function ItemIcon({ type }: { type: ItemType }) {
+ if (type === 'chart') return <Icons.LineChartOutlined iconSize="m" />;
+ if (type === 'dashboard') return <Icons.LayoutOutlined 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;
+ user_permission?: string | null;
+ }[]) || []
+ ).filter(r => r.user_permission !== 'implicit');
+ return {
+ items: results.map(toTransferItem),
+ total: (response.json.count as number) || 0,
+ };
+}
+
+export default function TransferModal({
+ currentFolderUuid,
+ currentFolderName,
+ currentFolderIsPrivate = false,
+ 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 [movedToRight, setMovedToRight] = useState<TransferItem[]>([]);
+ const [movedToLeft, setMovedToLeft] = useState<TransferItem[]>([]);
+ const [showDestinationBanner, setShowDestinationBanner] = useState(false);
+ const [leftSearch, setLeftSearch] = useState('');
+ const [rightSearch, setRightSearch] = useState('');
+ const leftSyncedSearch = useRef('');
+ const rightSyncedSearch = useRef('');
+ 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 { showConfirm, ConfirmModal } = useConfirmModal();
+
+ // Fetch left (source) first page + folder list on open
+ useEffect(() => {
+ if (!show) return;
+ setLeftSearch('');
+ setRightSearch('');
+ setLeftSelected(new Set());
+ setRightSelected(new Set());
+ setMovedToRight([]);
+ setMovedToLeft([]);
+ setShowDestinationBanner(false);
+ setRightItems([]);
+ setRightTotal(0);
+ setRightPage(0);
+ setTargetFolderUuid(null);
+ 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 && f.user_permission !== 'implicit',
+ );
+ 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);
+ return;
+ }
+ setRightSearch('');
+ setRightSelected(new Set());
+ if (movedToRight.length > 0) {
+ setShowDestinationBanner(true);
+ }
+ setRightLoading(true);
+ const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
+ fetchPage(apiUuid, 0)
+ .then(({ items, total }) => {
+ setRightItems(items);
+ setRightTotal(total);
+ setRightPage(0);
+ })
+ .catch(() => addDangerToast(t('Error loading folder contents')))
+ .finally(() => setRightLoading(false));
+ }, [show, targetFolderUuid, addDangerToast]);
+
+ // Debounced server-side search — server data only, no merging.
+ // Moved items are handled in the filtered memos below.
+ useEffect(() => {
+ if (!show) return;
+ const timer = setTimeout(() => {
+ setLeftLoading(true);
+ fetchPage(currentFolderUuid, 0, leftSearch || undefined)
+ .then(({ items, total }) => {
+ leftSyncedSearch.current = leftSearch;
+ 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]);
+
+ useEffect(() => {
+ if (!show || !targetFolderUuid) return;
+ const timer = setTimeout(() => {
+ setRightLoading(true);
+ const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
+ fetchPage(apiUuid, 0, rightSearch || undefined)
+ .then(({ items, total }) => {
+ rightSyncedSearch.current = rightSearch;
+ 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 handleScroll = useCallback(
+ (side: 'left' | 'right') => (e: UIEvent<HTMLDivElement>) => {
+ const isLeft = side === 'left';
+ const page = isLeft ? leftPage : rightPage;
+ const total = isLeft ? leftTotal : rightTotal;
+ const loading = isLeft ? leftLoading : rightLoading;
+ const search = isLeft ? leftSearch : rightSearch;
+ const setItems = isLeft ? setLeftItems : setRightItems;
+ const setPage = isLeft ? setLeftPage : setRightPage;
+ const setLoading = isLeft ? setLeftLoading : setRightLoading;
+ const folderUuid = isLeft
+ ? currentFolderUuid
+ : targetFolderUuid === '__root__'
+ ? null
+ : targetFolderUuid;
+
+ const el = e.currentTarget;
+ const nearBottom =
+ el.scrollTop > (el.scrollHeight - el.offsetHeight) * SCROLL_THRESHOLD;
+ const hasMore = (page + 1) * PAGE_SIZE < total;
+ if (!loading && hasMore && nearBottom) {
+ const nextPage = page + 1;
+ setLoading(true);
+ fetchPage(folderUuid, nextPage, search || undefined)
+ .then(({ items }) => {
+ setItems(prev => {
+ const existing = new Set(prev.map(i => i.key));
+ return [...prev, ...items.filter(i => !existing.has(i.key))];
+ });
+ setPage(nextPage);
+ })
+ .catch(() => addDangerToast(t('Error loading more items')))
+ .finally(() => setLoading(false));
+ }
+ },
+ [
+ leftPage,
+ leftTotal,
+ leftLoading,
+ leftSearch,
+ rightPage,
+ rightTotal,
+ rightLoading,
+ rightSearch,
+ currentFolderUuid,
+ targetFolderUuid,
+ addDangerToast,
+ ],
+ );
+
+ const toggleSelection = useCallback((key: string, side: 'left' | 'right') => {
+ const setter = side === 'left' ? setLeftSelected : setRightSelected;
+ setter(prev => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+
+ const selectAll = useCallback(
+ (filtered: TransferItem[], setter: typeof setLeftSelected) => {
+ setter(prev => new Set([...prev, ...filtered.map(i => i.key)]));
+ },
+ [],
+ );
+
+ const deselectAll = useCallback(
+ (filtered: TransferItem[], setter: typeof setLeftSelected) => {
+ setter(prev => {
+ const next = new Set(prev);
+ filtered.forEach(i => next.delete(i.key));
+ return next;
+ });
+ },
+ [],
+ );
+
+ const moveRight = useCallback(() => {
+ if (!leftSelected.size) return;
+ const moving = leftItems.filter(i => leftSelected.has(i.key));
+ const movingKeys = new Set(moving.map(i => i.key));
+ setMovedToRight(prev => [
+ ...prev.filter(i => !movingKeys.has(i.key)),
+ ...moving,
+ ]);
+ setMovedToLeft(prev => prev.filter(i => !movingKeys.has(i.key)));
+ setLeftSelected(new Set());
+ }, [leftSelected, leftItems]);
+
+ const moveLeft = useCallback(() => {
+ if (!rightSelected.size) return;
+ const moving = rightItems.filter(i => rightSelected.has(i.key));
+ const movingKeys = new Set(moving.map(i => i.key));
+ setMovedToLeft(prev => [
+ ...prev.filter(i => !movingKeys.has(i.key)),
+ ...moving,
+ ]);
+ setMovedToRight(prev => prev.filter(i => !movingKeys.has(i.key)));
+ setRightSelected(new Set());
+ }, [rightSelected, rightItems]);
+
+ const hasChanges = useMemo(
+ () => movedToRight.length > 0 || movedToLeft.length > 0,
+ [movedToRight, movedToLeft],
+ );
+
+ const targetFolderName = useMemo(() => {
+ if (!targetFolderUuid || targetFolderUuid === '__root__')
+ return t('Root (Analytics)');
+ return allFolders.find(f => f.uuid === targetFolderUuid)?.name ?? '';
+ }, [targetFolderUuid, allFolders]);
+
+ const movedItemNames = useMemo(() => {
+ const names = [
+ ...movedToRight.map(i => i.name),
+ ...movedToLeft.map(i => i.name),
+ ];
+ if (names.length <= 2) return names.join(` ${t('and')} `);
+ return `${names.slice(0, 2).join(', ')} ${t('and %s more', names.length - 2)}`;
+ }, [movedToRight, movedToLeft]);
+
+ const handleDone = useCallback(async () => {
+ if (!targetFolderUuid) return;
+ const targetApiUuid =
+ targetFolderUuid === '__root__' ? null : targetFolderUuid;
+ setSaving(true);
+ try {
+ const calls: Promise<unknown>[] = [];
+
+ // Items moved from left → right (add to target)
+ const assetsToAdd = movedToRight.filter(i => i.type !== 'folder');
+ const foldersToAdd = movedToRight.filter(i => i.type === 'folder');
+
+ if (assetsToAdd.length && targetApiUuid) {
+ 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) {
+ 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 },
+ }),
+ );
+ }
+ }
+
+ // Items moved from right → left (return to source)
+ for (const item of movedToLeft) {
+ 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,
+ movedToRight,
+ movedToLeft,
+ addSuccessToast,
+ addDangerToast,
+ onSuccess,
+ onHide,
+ ]);
+
+ const handleSave = useCallback(async () => {
+ if (!targetFolderUuid) return;
+ const sourceUuid =
+ currentFolderUuid && currentFolderUuid !== '__root__'
+ ? currentFolderUuid
+ : null;
+ const targetUuid =
+ targetFolderUuid === '__root__' ? null : targetFolderUuid;
+
+ // If both are root or same folder, skip permission check
+ if (sourceUuid === targetUuid) {
+ handleDone();
+ return;
+ }
+
+ // Check if crossing the Only Me boundary
+ const targetFolder = allFolders.find(f => f.uuid === targetUuid);
+ const targetIsPrivate = targetFolder?.is_private ?? false;
+
+ // Moving OUT of Only Me to a non-private folder
+ if (currentFolderIsPrivate && !targetIsPrivate) {
+ showConfirm({
+ title: t('Move item out of Only Me?'),
+ body: t(
+ 'Moving this item out of Only Me will apply the destination folder permissions and may change who can access it.',
+ ),
+ confirmText: t('Move'),
+ onConfirm: handleDone,
+ icon: null,
+ });
+ return;
+ }
+
+ // Moving between subfolders inside Only Me — no confirmation
+ if (currentFolderIsPrivate && targetIsPrivate) {
+ handleDone();
+ return;
+ }
+
+ // Standard permission diff check for non-private folders
+ try {
+ const [sourcePerms, targetPerms] = await Promise.all([
+ sourceUuid
+ ? SupersetClient.get({
+ endpoint: `/api/v1/folders/${sourceUuid}/subjects`,
+ }).then(r => r.json.result as { user_id: number }[])
+ : Promise.resolve([]),
+ targetUuid
+ ? SupersetClient.get({
+ endpoint: `/api/v1/folders/${targetUuid}/subjects`,
+ }).then(r => r.json.result as { user_id: number }[])
+ : Promise.resolve([]),
+ ]);
+
+ const sourceIds = new Set(sourcePerms.map(s => s.user_id));
+ const targetIds = new Set(targetPerms.map(s => s.user_id));
+
+ const permsDiffer =
+ sourceIds.size !== targetIds.size ||
+ [...sourceIds].some(id => !targetIds.has(id));
+
+ if (permsDiffer) {
+ showConfirm({
+ title: t('Move to %s?', targetFolderName),
+ body: t(
+ 'Anyone with access to this folder will be able to see %s.',
+ movedItemNames,
+ ),
+ confirmText: t('Move'),
+ onConfirm: handleDone,
+ icon: null,
+ });
+ } else {
+ handleDone();
+ }
+ } catch {
+ // If permission check fails, proceed without confirmation
+ handleDone();
+ }
+ }, [
+ targetFolderUuid,
+ currentFolderUuid,
+ currentFolderIsPrivate,
+ allFolders,
+ handleDone,
+ showConfirm,
+ targetFolderName,
+ movedItemNames,
+ ]);
+
+ const filteredLeft = useMemo(() => {
+ const movedRightKeys = new Set(movedToRight.map(i => i.key));
+ // Server items minus anything moved to the right
+ let items = leftItems.filter(i => !movedRightKeys.has(i.key));
+ if (targetFolderUuid) {
+ const rightKeys = new Set(rightItems.map(i => i.key));
+ items = items.filter(
+ i =>
+ !(i.type === 'folder' && i.uuid === targetFolderUuid) &&
+ !rightKeys.has(i.key),
+ );
+ }
+ // Append moved-to-left items filtered by the synced search value,
+ // so they disappear at the same time as server results.
+ const serverKeys = new Set(items.map(i => i.key));
+ const q = leftSyncedSearch.current?.toLowerCase();
+ const localMatches = movedToLeft.filter(
+ i => !serverKeys.has(i.key) && (!q || i.name.toLowerCase().includes(q)),
+ );
+ return [...items, ...localMatches];
+ }, [leftItems, movedToRight, movedToLeft, targetFolderUuid, rightItems]);
+
+ const filteredRight = useMemo(() => {
+ const movedLeftKeys = new Set(movedToLeft.map(i => i.key));
+ const items = rightItems.filter(
+ i =>
+ !movedLeftKeys.has(i.key) &&
+ !(i.type === 'folder' && i.uuid === currentFolderUuid),
+ );
+ // Append moved-to-right items filtered by the synced search value,
+ // so they disappear at the same time as server results.
+ const serverKeys = new Set(items.map(i => i.key));
+ const q = rightSyncedSearch.current?.toLowerCase();
+ const localMatches = movedToRight.filter(
+ i => !serverKeys.has(i.key) && (!q || i.name.toLowerCase().includes(q)),
+ );
+ return [...items, ...localMatches];
+ }, [rightItems, movedToLeft, movedToRight, 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: ReactNode;
+ children: TreeNode[];
+ };
+ const nodeMap = new Map<string, TreeNode & { name: string }>();
+ for (const f of eligible) {
+ nodeMap.set(f.uuid, {
+ value: f.uuid,
+ name: f.name,
+ title: (
+ <Flex align="center" gap={4}>
+ <Icons.FolderOutlined iconSize="m" />
+ {f.name}
+ </Flex>
+ ),
+ 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: (
+ <Flex align="center" gap={4}>
+ <Icons.HomeOutlined iconSize="m" />
+ {t('Root (Analytics)')}
+ </Flex>
+ ),
+ children: [],
+ });
+ }
+
+ return roots;
+ }, [allFolders, currentFolderUuid, leftItems, leftSelected]);
+
+ return (
+ <>
+ <StandardModal
+ title={t('Move items')}
+ show={show}
+ onHide={onHide}
+ onSave={handleSave}
+ saveText={t('Save')}
+ saveLoading={saving}
+ saveDisabled={!hasChanges || !targetFolderUuid}
+ width={MODAL_LARGE_WIDTH}
+ >
+ <ModalContent>
+ <PanelContainer>
+ <Panel>
+ <Field>
+ <StyledFormLabel>{t('Source folder')}</StyledFormLabel>
+ <Input
+ value={currentFolderName || t('Root (Analytics)')}
+ disabled
+ />
+ </Field>
+ <Input
+ placeholder={t('Search the folder')}
+ value={leftSearch}
+ onChange={e => setLeftSearch(e.target.value)}
+ allowClear
+ prefix={<Icons.SearchOutlined iconSize="l" />}
+ />
+ <Field>
+ <BulkActionsBar gap={theme.sizeUnit}>
+ <Button
+ buttonStyle="link"
+ buttonSize="xsmall"
+ disabled={leftSelectableCount === 0}
+ onClick={() => selectAll(filteredLeft, setLeftSelected)}
+ >
+ {t('Select all (%s)', formatCount(leftSelectableCount))}
+ </Button>
+ <Button
+ buttonStyle="link"
+ buttonSize="xsmall"
+ disabled={leftDeselectableCount === 0}
+ onClick={() => deselectAll(filteredLeft, setLeftSelected)}
+ >
+ {t('Deselect all (%s)', formatCount(leftDeselectableCount))}
+ </Button>
+ <PanelCount css={{ marginLeft: 'auto' }}>
+ {leftSelected.size > 0
+ ? t(
+ '%s/%s items selected',
+ formatCount(leftSelected.size),
+ formatCount(leftTotal),
+ )
+ : t('0 items selected')}
+ </PanelCount>
+ </BulkActionsBar>
+ {leftLoading && !leftItems.length ? (
+ <Skeleton active />
+ ) : (
+ <ScrollableList onScroll={handleScroll('left')}>
+ <List
+ dataSource={filteredLeft}
+ locale={{ emptyText: t('No items') }}
+ renderItem={(item: TransferItem) => (
+ <List.Item
+ key={item.key}
+ onClick={() => toggleSelection(item.key, 'left')}
+ 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>
+ <StyledFormLabel>{t('Destination folder')}</StyledFormLabel>
+ <TreeSelect
+ aria-label={t('Target folder')}
+ placeholder={t('Select a location…')}
+ suffixIcon={<Icons.DownOutlined iconSize="m" />}
+ treeIcon
+ treeData={folderTreeData}
+ value={targetFolderUuid}
+ onChange={(val: string) => setTargetFolderUuid(val)}
+ allowClear
+ treeDefaultExpandAll
+ showSearch
+ filterTreeNode={(input, node) =>
+ (node as any)?.name
+ ?.toLowerCase()
+ .includes(input.toLowerCase()) ?? false
+ }
+ getPopupContainer={trigger =>
+ trigger.closest('.ant-modal-content') || document.body
+ }
+ css={{ width: '100%' }}
+ />
+ </Field>
+ <Input
+ placeholder={t('Search the folder')}
+ value={rightSearch}
+ onChange={e => setRightSearch(e.target.value)}
+ allowClear
+ prefix={<Icons.SearchOutlined iconSize="l" />}
+ />
+ {showDestinationBanner && (
+ <Alert
+ type="warning"
+ showIcon
+ closable
+ onClose={() => setShowDestinationBanner(false)}
+ message={t(
+ 'Destination folder changed. Previously moved items will be saved to the new destination.',
+ )}
+ />
+ )}
+ <Field>
+ <BulkActionsBar gap={theme.sizeUnit}>
+ <Button
+ buttonStyle="link"
+ buttonSize="xsmall"
+ disabled={rightSelectableCount === 0}
+ onClick={() => selectAll(filteredRight, setRightSelected)}
+ >
+ {t('Select all (%s)', formatCount(rightSelectableCount))}
+ </Button>
+ <Button
+ buttonStyle="link"
+ buttonSize="xsmall"
+ disabled={rightDeselectableCount === 0}
+ onClick={() => deselectAll(filteredRight, setRightSelected)}
+ >
+ {t(
+ 'Deselect all (%s)',
+ formatCount(rightDeselectableCount),
+ )}
+ </Button>
+ <PanelCount css={{ marginLeft: 'auto' }}>
+ {rightSelected.size > 0
+ ? t(
+ '%s/%s items selected',
+ formatCount(rightSelected.size),
+ formatCount(rightTotal),
+ )
+ : t('0 items selected')}
+ </PanelCount>
+ </BulkActionsBar>
+ {!targetFolderUuid ? (
+ <EmptyState>
+ {t('Select a location to see its contents')}
+ </EmptyState>
+ ) : rightLoading && !rightItems.length ? (
+ <Skeleton active />
+ ) : (
+ <ScrollableList onScroll={handleScroll('right')}>
+ <List
+ dataSource={filteredRight}
+ locale={{ emptyText: t('No items') }}
+ renderItem={(item: TransferItem) => (
+ <List.Item
+ key={item.key}
+ onClick={() => toggleSelection(item.key, 'right')}
+ 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>
+ {ConfirmModal}
+ </>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
new file mode 100644
index 00000000000..0a388f76d7d
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -0,0 +1,1783 @@
+/**
+ * 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, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ isFeatureEnabled,
+ FeatureFlag,
+ getChartMetadataRegistry,
+ SupersetClient,
+} from '@superset-ui/core';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+// eslint-disable-next-line no-restricted-imports -- icons not yet in AntdEnhanced on 6.0-release
+import {
+ MinusSquareOutlined,
+ PlusSquareOutlined,
+ PushpinOutlined,
+} from '@ant-design/icons';
+import rison from 'rison';
+import { useHistory, useParams } from 'react-router-dom';
+import type { CellProps } from 'react-table';
+import {
+ ImportModal as ImportModelsModal,
+ ListView,
+ ListViewFilterOperator as FilterOperator,
+ ModifiedInfo,
+ TagType,
+ TagsList,
+ type ListViewFilters,
+} from 'src/components';
+import { SubjectPile } from 'src/features/subjects/SubjectPile';
+import { TagTypeEnum } from 'src/components/Tag/TagType';
+import { loadTags } from 'src/components/Tag/utils';
+import {
+ Button,
+ ConfirmStatusChange,
+ Dropdown,
+ Flex,
+ InfoTooltip,
+ Tooltip,
+} from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { FolderBreadcrumb, type FolderBreadcrumbItem } from '../components';
+import SubMenu, { type SubMenuProps } from 'src/features/home/SubMenu';
+import withToasts from 'src/components/MessageToasts/withToasts';
+import {
+ createErrorHandler,
+ createFetchRelated,
+ handleChartDelete,
+ handleDashboardDelete,
+} from 'src/views/CRUD/utils';
+import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils';
+import handleResourceExport from 'src/utils/export';
+import ChartPropertiesModal from 'src/explore/components/PropertiesModal';
+import DashboardPropertiesModal from 'src/dashboard/components/PropertiesModal';
+import { type Slice } from 'src/types/Chart';
+import CreateFolderModal from './CreateFolderModal';
+import TransferModal from './TransferModal';
+import FolderPermissionsModal from './FolderPermissionsModal';
+import EditFolderModal from './EditFolderModal';
+import DeleteFolderModal from './DeleteFolderModal';
+import FolderSelector from './FolderSelector';
+import DashboardCharts from './DashboardCharts';
+import type { ContentItem } from './types';
+
+/**
+ * withToasts types the properties modals as ComponentType<any>, so the
+ * renderExtraFields callback argument has to be annotated at the call site.
+ */
+type ExtraFieldsContext = {
+ assetId: number;
+ assetType: 'chart' | 'dashboard';
+ accessorCount: number;
+};
+
+const PASSWORDS_NEEDED_MESSAGE = t(
+ 'The passwords for the databases below are needed in order to ' +
+ 'import them together with the assets. Please note that the ' +
+ '"Secure Extra" and "Certificate" sections of ' +
+ 'the database configuration are not present in export files, and ' +
+ 'should be added manually after the import if they are needed.',
+);
+const CONFIRM_OVERWRITE_MESSAGE = t(
+ 'You are importing one or more assets that already exist. ' +
+ 'Overwriting might cause you to lose some of your work. Are you ' +
+ 'sure you want to overwrite?',
+);
+
+/** A location in the drill path; the root has a null uuid. */
+interface Crumb {
+ uuid: string | null;
+ name: string;
+ user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+ is_only_me?: boolean;
+}
+
+interface SortColumn {
+ id: string;
+ desc?: boolean;
+}
+
+interface FetchConfig {
+ pageIndex: number;
+ pageSize: number;
+ sortBy: SortColumn[];
+ filters: { id: string; operator?: string; value?: unknown }[];
+}
+
+const PAGE_SIZE = 25;
+const FOLDER_TYPE = 'analytics';
+
+const Actions = styled.div`
+ ${({ theme }) => `
+ color: ${theme.colorIcon};
+ display: flex;
+ gap: ${theme.sizeUnit * 2}px;
+
+ .action-button {
+ cursor: pointer;
+ &:hover {
+ color: ${theme.colorPrimary};
+ }
+ }
+ `}
+`;
+
+const NameLink = styled.a`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ cursor: pointer;
+ `}
+`;
+
+const NameRow = styled.span`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+// Expand/collapse caret rendered immediately after a dashboard's title.
+const CaretButton = styled.button`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: ${theme.colorIcon};
+ cursor: pointer;
+ &:hover {
+ color: ${theme.colorPrimary};
+ }
+ `}
+`;
+
+const BreadcrumbWrap = styled.div`
+ padding: 0;
+`;
+
+const chartRegistry = getChartMetadataRegistry();
+
+const createFetchDatasets = async (
+ filterValue = '',
+ page: number,
+ pageSize: number,
+) => {
+ const filters = filterValue
+ ? { filters: [{ col: 'table_name', opr: 'sw', value: filterValue }] }
+ : {};
+ const queryParams = rison.encode({
+ columns: ['table_name', 'id'],
+ keys: ['none'],
+ order_column: 'table_name',
+ order_direction: 'asc',
+ page,
+ page_size: pageSize,
+ ...filters,
+ });
+ const { json = {} } = await SupersetClient.get({
+ endpoint: `/api/v1/dataset/?q=${queryParams}`,
+ });
+ return {
+ data: (json?.result ?? []).map(
+ ({ table_name: tableName, id }: { table_name: string; id: number }) => ({
+ label: tableName,
+ value: id,
+ }),
+ ),
+ totalCount: json?.count ?? 0,
+ };
+};
+
+function AnalyticsList({
+ addDangerToast,
+ addSuccessToast,
+}: {
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+}) {
+ const history = useHistory();
+ const { folderUuid } = useParams<{ folderUuid?: string }>();
+ const theme = useTheme();
+ const currentUserId = useSelector<any, number>(state => state.user?.userId);
+ const [loading, setLoading] = useState(true);
+ const [items, setItems] = useState<ContentItem[]>([]);
+ const [count, setCount] = useState(0);
+ const [breadcrumb, setBreadcrumb] = useState<Crumb[]>(() => [
+ { uuid: null, name: t('Analytics') },
+ ]);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [hasFolders, setHasFolders] = useState(true);
+ // Charts per expanded dashboard, owned here so they survive antd remounting
+ // the expanded-row subtree (which would otherwise re-flash the loader).
+ const [chartsByDashboard, setChartsByDashboard] = useState<
+ Record<number, { loading: boolean; charts: ContentItem[] }>
+ >({});
+ const [showCreateFolder, setShowCreateFolder] = useState(false);
+ const [folderToManagePerms, setFolderToManagePerms] =
+ useState<ContentItem | null>(null);
+ const [folderToRename, setFolderToRename] = useState<ContentItem | null>(
+ null,
+ );
+ const [editChart, setEditChart] = useState<Slice | null>(null);
+ const [editDashboard, setEditDashboard] = useState<{
+ id: number;
+ title: string;
+ } | null>(null);
+ const [folderValidationError, setFolderValidationError] = useState<
+ string | null
+ >(null);
+ const pendingFolderMove = useRef<{
+ assetType: 'chart' | 'dashboard';
+ assetId: number;
+ folderUuid: string | null;
+ } | null>(null);
+ const executePendingFolderMove = useCallback(async () => {
+ const move = pendingFolderMove.current;
+ if (!move) return;
+ pendingFolderMove.current = null;
+ try {
+ if (move.folderUuid) {
+ await SupersetClient.post({
+ endpoint: `/api/v1/folders/${move.folderUuid}/assets`,
+ jsonPayload: {
+ assets: [{ type: move.assetType, id: move.assetId }],
+ },
+ });
+ } else {
+ // Move to root — remove from current folder
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/folders/asset-location?type=${move.assetType}&id=${move.assetId}`,
+ });
+ const currentFolder = json?.result?.folder_uuid;
+ if (currentFolder) {
+ await SupersetClient.delete({
+ endpoint: `/api/v1/folders/${currentFolder}/assets?q=${encodeURIComponent(
+ JSON.stringify([{ type: move.assetType, id: move.assetId }]),
+ )}`,
+ });
+ }
+ }
+ } catch {
+ addDangerToast(t('Error moving item to folder'));
+ }
+ }, [addDangerToast]);
+ const [bulkSelectEnabled, setBulkSelectEnabled] = useState(false);
+ const [moveTarget, setMoveTarget] = useState<ContentItem | null>(null);
+ const [showTransferModal, setShowTransferModal] = useState(false);
+ // Queue of folders awaiting the DeleteFolderModal — used for both single-
+ // and bulk-delete flows. The modal shows for queue[0] and pops on close.
+ const [folderDeleteQueue, setFolderDeleteQueue] = useState<ContentItem[]>([]);
+ // Batch-delete result deferred until the folder modal queue is empty, so
+ // toasts appear only after the user has finished handling all folder prompts.
+ const [pendingBatchResult, setPendingBatchResult] = useState<{
+ succeeded: number;
+ failed: number;
+ } | null>(null);
+
+ const [importResource, setImportResource] = useState<
+ 'chart' | 'dashboard' | null
+ >(null);
+ const [passwordFields, setPasswordFields] = useState<string[]>([]);
+ const [sshTunnelPasswordFields, setSSHTunnelPasswordFields] = useState<
+ string[]
+ >([]);
+ const [sshTunnelPrivateKeyFields, setSSHTunnelPrivateKeyFields] = useState<
+ string[]
+ >([]);
+ const [
+ sshTunnelPrivateKeyPasswordFields,
+ setSSHTunnelPrivateKeyPasswordFields,
+ ] = useState<string[]>([]);
+
+ // --- Pinning state ---
+ const [pins, setPins] = useState<
+ Array<{
+ id: number;
+ object_id: number;
+ object_type: string;
+ position: number;
+ }>
+ >([]);
+ const pinnedKeys = useMemo(
+ () => new Set(pins.map(p => `${p.object_type}-${p.object_id}`)),
+ [pins],
+ );
+ const isPinned = useCallback(
+ (item: ContentItem) => pinnedKeys.has(`${item.type}-${item.id}`),
+ [pinnedKeys],
+ );
+ const fetchPins = useCallback(async () => {
+ try {
+ const { json } = await SupersetClient.get({
+ endpoint: '/api/v1/folders/pins',
+ });
+ setPins(json.result || []);
+ } catch {
+ // silently fail — pins are non-critical
+ }
+ }, []);
+
+ // Clear stale items when navigating to a different folder
+ useEffect(() => {
+ setItems([]);
+ setCount(0);
+ }, [folderUuid]);
+
+ // Fetch pins only at root level (pins are root-only)
+ useEffect(() => {
+ if (!folderUuid) fetchPins();
+ }, [fetchPins, refreshKey, folderUuid]);
+
+ // Check if any folders exist (for disabling "Move assets")
+ useEffect(() => {
+ SupersetClient.get({
+ endpoint: `/api/v1/folders/?folder_type=${FOLDER_TYPE}`,
+ })
+ .then(({ json }) => setHasFolders((json.count ?? 0) > 0))
+ .catch(() => setHasFolders(false));
+ }, [refreshKey]);
+
+ const refreshData = useCallback(() => setRefreshKey(key => key + 1), []);
+
+ const pinItem = useCallback(
+ async (item: ContentItem) => {
+ if (pinnedKeys.size >= 3) {
+ addDangerToast(t('Maximum 3 pins allowed'));
+ return;
+ }
+ const usedPositions = new Set(pins.map(p => p.position));
+ const nextPosition = [1, 2, 3].find(p => !usedPositions.has(p)) ?? 1;
+ try {
+ await SupersetClient.post({
+ endpoint: '/api/v1/folders/pins',
+ jsonPayload: {
+ object_id: item.id,
+ object_type: item.type,
+ position: nextPosition,
+ },
+ });
+ addSuccessToast(t('Pinned: %s', item.name));
+ fetchPins();
+ refreshData();
+ } catch {
+ addDangerToast(t('Error pinning item'));
+ }
+ },
+ [
+ pinnedKeys.size,
+ pins,
+ addSuccessToast,
+ addDangerToast,
+ fetchPins,
+ refreshData,
+ ],
+ );
+
+ const unpinItem = useCallback(
+ async (item: ContentItem) => {
+ const pin = pins.find(
+ p => p.object_type === item.type && p.object_id === item.id,
+ );
+ if (!pin) return;
+ try {
+ await SupersetClient.delete({
+ endpoint: `/api/v1/folders/pins/${pin.id}`,
+ });
+ addSuccessToast(t('Unpinned: %s', item.name));
+ fetchPins();
+ refreshData();
+ } catch {
+ addDangerToast(t('Error unpinning item'));
+ }
+ },
+ [pins, addSuccessToast, addDangerToast, fetchPins, refreshData],
+ );
+
+ const currentFolder = breadcrumb[breadcrumb.length - 1];
+ const isAtRoot = currentFolder.uuid === null;
+ const canEditCurrentFolder =
+ isAtRoot || currentFolder.user_permission === 'editor';
+ // True while the breadcrumb effect is still resolving the URL folder UUID.
+ // Prevents ListView from firing a fetch against the root endpoint on reload.
+ const breadcrumbLoading = !!folderUuid && isAtRoot;
+
+ const fetchData = useCallback(
+ async ({ pageIndex, pageSize, sortBy, filters }: FetchConfig) => {
+ setLoading(true);
+ setExpandedKeys([]);
+ setChartsByDashboard({});
+
+ // Track the name filter for search highlighting
+ const nameFilter = filters.find(f => f.id === 'name');
+ setSearchQuery(
+ nameFilter?.value
+ ? String(
+ typeof nameFilter.value === 'object' &&
+ nameFilter.value !== null &&
+ 'value' in nameFilter.value
+ ? (nameFilter.value as { value: unknown }).value
+ : nameFilter.value,
+ )
+ : '',
+ );
+
+ // Build rison-encoded filters matching the standard {col, opr, value} format
+ const filterExps: Array<{ col: string; opr: string; value: unknown }> =
+ [];
+ filters.forEach(({ id, operator, value }) => {
+ if (value === undefined || value === null || value === '') return;
+ const scalar =
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ value !== null &&
+ 'value' in value
+ ? (value as { value: unknown }).value
+ : value;
+ switch (id) {
+ case 'name':
+ filterExps.push({ col: 'name', opr: 'ct', value: scalar });
+ break;
+ case 'type':
+ filterExps.push({ col: 'type', opr: 'in', value: scalar });
+ break;
+ case 'editors':
+ filterExps.push({ col: 'editors', opr: 'rel_m_m', value: scalar });
+ break;
+ case 'viewers':
+ filterExps.push({ col: 'viewers', opr: 'rel_m_m', value: scalar });
+ break;
+ case 'changed_on':
+ if (operator === 'between' && Array.isArray(scalar)) {
+ filterExps.push({
+ col: 'changed_on',
+ opr: 'gt',
+ value: scalar[0],
+ });
+ filterExps.push({
+ col: 'changed_on',
+ opr: 'lt',
+ value: scalar[1],
+ });
+ } else if (operator === 'gt') {
+ filterExps.push({ col: 'changed_on', opr: 'gt', value: scalar });
+ } else if (operator === 'lt') {
+ filterExps.push({ col: 'changed_on', opr: 'lt', value: scalar });
+ }
+ break;
+ case 'viz_type':
+ filterExps.push({ col: 'viz_type', opr: 'in', value: scalar });
+ break;
+ case 'datasource':
+ filterExps.push({ col: 'dataset', opr: 'in', value: scalar });
+ break;
+ case 'tags':
+ filterExps.push({ col: 'tags', opr: 'rel_m_m', value: scalar });
+ break;
+ default:
+ break;
+ }
+ });
+
+ const queryParams = rison.encode_uri({
+ order_column: sortBy[0]?.id ?? 'changed_on',
+ order_direction: sortBy[0]?.desc === false ? 'asc' : 'desc',
+ page: pageIndex,
+ page_size: pageSize,
+ ...(filterExps.length ? { filters: filterExps } : {}),
+ });
+
+ const base = currentFolder.uuid
+ ? `/api/v1/folders/${currentFolder.uuid}/assets`
+ : `/api/v1/folders/assets?folder_type=${FOLDER_TYPE}&`;
+ const separator = base.includes('?') ? '' : '?';
+ try {
+ const { json } = await SupersetClient.get({
+ endpoint: `${base}${separator}q=${queryParams}`,
+ });
+ setItems((json.result as ContentItem[]) || []);
+ setCount((json.count as number) || 0);
+ } catch {
+ addDangerToast(t('Error fetching folder contents'));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [currentFolder.uuid, addDangerToast],
+ );
+
+ // Sync URL → breadcrumb on mount, back/forward, or direct link
+ useEffect(() => {
+ const currentUuid = breadcrumb[breadcrumb.length - 1]?.uuid ?? undefined;
+ if ((folderUuid ?? undefined) === (currentUuid ?? undefined)) return;
+
+ if (!folderUuid) {
+ setBreadcrumb([{ uuid: null, name: t('Analytics') }]);
+ return;
+ }
+
+ let cancelled = false;
+ (async () => {
+ const crumbs: Crumb[] = [];
+ let uuid: string | null = folderUuid;
+ try {
+ while (uuid) {
+ const response = await SupersetClient.get({
+ endpoint: `/api/v1/folders/${uuid}`,
+ });
+ const folder = response.json.result as {
+ uuid: string;
+ name: string;
+ parent_uuid: string | null;
+ user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+ is_only_me?: boolean;
+ };
+ crumbs.unshift({
+ uuid: folder.uuid,
+ name: folder.name,
+ user_permission: folder.user_permission,
+ is_only_me: folder.is_only_me,
+ });
+ uuid = folder.parent_uuid;
+ }
+ crumbs.unshift({ uuid: null, name: t('Analytics') });
+ if (!cancelled) setBreadcrumb(crumbs);
+ } catch {
+ if (!cancelled) {
+ addDangerToast(t('Folder not found'));
+ history.replace('/analytics/');
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [folderUuid]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const drillInto = useCallback(
+ (item: ContentItem) => {
+ setExpandedKeys([]);
+ // Breadcrumb is resolved by the URL-sync effect from the folder's parent chain,
+ // so we only need to push the URL. This handles both normal navigation and
+ // jumping to deeply nested folders from search results.
+ history.push(`/analytics/${item.uuid}/`);
+ },
+ [history],
+ );
+
+ const navigateToCrumb = useCallback(
+ (index: number) => {
+ setExpandedKeys([]);
+ setBreadcrumb(prev => {
+ const sliced = prev.slice(0, index + 1);
+ const target = sliced[sliced.length - 1];
+ history.push(
+ target.uuid ? `/analytics/${target.uuid}/` : '/analytics/',
+ );
+ return sliced;
+ });
+ },
+ [history],
+ );
+
+ const toggleExpand = useCallback((rowId: string) => {
+ setExpandedKeys(prev =>
+ prev.includes(rowId)
+ ? prev.filter(key => key !== rowId)
+ : [...prev, rowId],
+ );
+ }, []);
+
+ // Lazily load a dashboard's charts, deduped via the functional updater so
+ // repeated remounts of DashboardCharts don't refetch an already-loaded entry.
+ const requestDashboardCharts = useCallback(
+ (dashboardId: number) => {
+ setChartsByDashboard(prev => {
+ if (prev[dashboardId]) return prev;
+ (async () => {
+ try {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/folders/dashboard-charts/${dashboardId}`,
+ });
+ const charts = (json.result as ContentItem[]) || [];
+ setChartsByDashboard(curr => ({
+ ...curr,
+ [dashboardId]: { loading: false, charts },
+ }));
+ } catch {
+ addDangerToast(t('Error loading dashboard charts'));
+ setChartsByDashboard(curr => ({
+ ...curr,
+ [dashboardId]: { loading: false, charts: [] },
+ }));
+ }
+ })();
+ return { ...prev, [dashboardId]: { loading: true, charts: [] } };
+ });
+ },
+ [addDangerToast],
+ );
+
+ // Mirror ChartList: PropertiesModal fetches the chart itself by `slice_id`,
+ // so it only needs a minimal slice built from the row (no pre-fetch).
+ const openChartEdit = useCallback(
+ (item: ContentItem) =>
+ setEditChart({ slice_id: item.id, slice_name: item.name } as Slice),
+ [],
+ );
+
+ const exportAsset = useCallback((item: ContentItem) => {
+ handleResourceExport(
+ item.type === 'chart' ? 'chart' : 'dashboard',
+ [item.id],
+ () => {},
+ );
+ }, []);
+
+ const deleteAsset = useCallback(
+ (item: ContentItem) => {
+ if (item.type === 'chart') {
+ handleChartDelete(
+ { id: item.id, slice_name: item.name } as Parameters<
+ typeof handleChartDelete
+ >[0],
+ addSuccessToast,
+ addDangerToast,
+ refreshData,
+ );
+ } else {
+ handleDashboardDelete(
+ { id: item.id, dashboard_title: item.name } as Parameters<
+ typeof handleDashboardDelete
+ >[0],
+ refreshData,
+ addSuccessToast,
+ addDangerToast,
+ );
+ }
+ },
+ [addSuccessToast, addDangerToast, refreshData],
+ );
+
+ // Show deferred batch-delete toasts once the folder modal queue drains.
+ useEffect(() => {
+ if (!pendingBatchResult || folderDeleteQueue.length > 0) return;
+ const { succeeded, failed } = pendingBatchResult;
+ if (failed === 0) {
+ addSuccessToast(t('Successfully deleted selected items'));
+ } else if (succeeded > 0) {
+ addSuccessToast(t('Some items were deleted successfully'));
+ addDangerToast(t('Some items could not be deleted'));
+ } else {
+ addDangerToast(t('There was an issue deleting the selected items'));
+ }
+ setPendingBatchResult(null);
+ }, [folderDeleteQueue.length, pendingBatchResult]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleBulkDelete = useCallback(
+ async (rows: ContentItem[]) => {
+ const charts = rows.filter(r => r.type === 'chart').map(r => r.id);
+ const dashboards = rows
+ .filter(r => r.type === 'dashboard')
+ .map(r => r.id);
+ const emptyFolders = rows.filter(
+ r =>
+ r.type === 'folder' &&
+ (r.asset_count ?? 0) === 0 &&
+ (r.children_count ?? 0) === 0,
+ );
+ const nonEmptyFolders = rows.filter(
+ r =>
+ r.type === 'folder' &&
+ ((r.asset_count ?? 0) > 0 || (r.children_count ?? 0) > 0),
+ );
+
+ const calls: Promise<unknown>[] = [];
+ if (charts.length) {
+ calls.push(
+ SupersetClient.delete({
+ endpoint: `/api/v1/chart/?q=${rison.encode(charts)}`,
+ }),
+ );
+ }
+ if (dashboards.length) {
+ calls.push(
+ SupersetClient.delete({
+ endpoint: `/api/v1/dashboard/?q=${rison.encode(dashboards)}`,
+ }),
+ );
+ }
+ emptyFolders.forEach(f =>
+ calls.push(
+ SupersetClient.delete({ endpoint: `/api/v1/folders/${f.uuid}` }),
+ ),
+ );
+
+ if (calls.length) {
+ const results = await Promise.allSettled(calls);
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
+ const failed = results.filter(r => r.status === 'rejected').length;
+ if (succeeded > 0) refreshData();
+ if (nonEmptyFolders.length > 0) {
+ // Defer toasts until after all folder modals have been handled.
+ setPendingBatchResult({ succeeded, failed });
+ } else if (failed === 0) {
+ addSuccessToast(t('Successfully deleted selected items'));
+ } else if (succeeded > 0) {
+ addSuccessToast(t('Some items were deleted successfully'));
+ addDangerToast(t('Some items could not be deleted'));
+ } else {
+ addDangerToast(t('There was an issue deleting the selected items'));
+ }
+ }
+
+ // Non-empty folders need individual confirmation via DeleteFolderModal.
+ if (nonEmptyFolders.length > 0) {
+ setFolderDeleteQueue(nonEmptyFolders);
+ }
+ },
+ [addSuccessToast, addDangerToast, refreshData],
+ );
+
+ const handleBulkExport = useCallback((rows: ContentItem[]) => {
+ const charts = rows.filter(r => r.type === 'chart').map(r => r.id);
+ const dashboards = rows.filter(r => r.type === 'dashboard').map(r => r.id);
+ if (charts.length) handleResourceExport('chart', charts, () => {});
+ if (dashboards.length)
+ handleResourceExport('dashboard', dashboards, () => {});
+ }, []);
+
+ const highlightName = useCallback(
+ (name: string) => {
+ if (!searchQuery) return name;
+ const idx = name.toLowerCase().indexOf(searchQuery.toLowerCase());
+ if (idx === -1) return name;
+ return (
+ <span>
+ {name.slice(0, idx)}
+ <mark css={{ background: theme.colorPrimaryBg, padding: 0 }}>
+ {name.slice(idx, idx + searchQuery.length)}
+ </mark>
+ {name.slice(idx + searchQuery.length)}
+ </span>
+ );
+ },
+ [searchQuery, theme.colorPrimaryBg],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ accessor: 'name',
+ Header: t('Name'),
+ id: 'name',
+ Cell: ({ row: { original, id } }: CellProps<ContentItem>) => {
+ if (!original.type) {
+ return null;
+ }
+ const pinIcon =
+ isAtRoot && (isPinned(original) || original.is_only_me) ? (
+ <PushpinOutlined
+ style={{ fontSize: 12, color: theme.colorPrimary }}
+ />
+ ) : null;
+
+ const contextMenuItems: Array<{
+ key: string;
+ label: string;
+ disabled?: boolean;
+ onClick: () => void;
+ }> = [];
+
+ if (isAtRoot && !original.is_only_me) {
+ if (isPinned(original)) {
+ contextMenuItems.push({
+ key: 'unpin',
+ label: t('Unpin'),
+ onClick: () => unpinItem(original),
+ });
+ } else {
+ contextMenuItems.push({
+ key: 'pin',
+ label:
+ pinnedKeys.size >= 3 ? t('Pin (max 3 reached)') : t('Pin'),
+ disabled: pinnedKeys.size >= 3,
+ onClick: () => pinItem(original),
+ });
+ }
+ }
+ if (
+ canEditCurrentFolder &&
+ !original.is_only_me &&
+ original.user_permission !== 'implicit'
+ ) {
+ contextMenuItems.push({
+ key: 'move',
+ label: t('Move to…'),
+ onClick: () => setMoveTarget(original),
+ });
+ }
+
+ const nameContent = (() => {
+ if (original.type === 'folder') {
+ return (
+ <Tooltip
+ title={
+ original.user_permission === 'implicit'
+ ? t('You have limited access to this folder')
+ : undefined
+ }
+ >
+ <NameLink
+ role="button"
+ tabIndex={0}
+ onClick={() => drillInto(original)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') drillInto(original);
+ }}
+ >
+ {original.is_private ? (
+ <Icons.LockOutlined
+ iconSize="m"
+ css={{ color: theme.colorTextSecondary }}
+ />
+ ) : (
+ <Icons.FolderOutlined
+ iconSize="m"
+ css={{
+ color: original.parent_uuid
+ ? theme.colorWarning
+ : theme.colorSuccess,
+ }}
+ />
+ )}
+ {highlightName(original.name)}
+ {original.is_private && (
+ <span
+ css={{
+ fontSize: theme.fontSizeXS,
+ padding: `0 ${theme.sizeUnit}px`,
+ borderRadius: theme.borderRadius,
+ background: theme.colorBgLayout,
+ color: theme.colorTextSecondary,
+ }}
+ >
+ {t('Private')}
+ </span>
+ )}
+ {pinIcon}
+ </NameLink>
+ </Tooltip>
+ );
+ }
+ if (original.type === 'dashboard') {
+ const expanded = expandedKeys.includes(id);
+ return (
+ <NameRow>
+ <Icons.LayoutOutlined
+ iconSize="m"
+ css={{ color: theme.colorWarning }}
+ />
+ {original.url ? (
+ <NameLink href={original.url}>
+ {highlightName(original.name)}
+ </NameLink>
+ ) : (
+ highlightName(original.name)
+ )}
+ {pinIcon}
+ <CaretButton
+ type="button"
+ aria-label={expanded ? t('Collapse') : t('Expand')}
+ onClick={() => toggleExpand(id)}
+ >
+ {expanded ? (
+ <MinusSquareOutlined style={{ fontSize: 12 }} />
+ ) : (
+ <PlusSquareOutlined style={{ fontSize: 12 }} />
+ )}
+ </CaretButton>
+ </NameRow>
+ );
+ }
+ return original.url ? (
+ <NameLink href={original.url}>
+ <Icons.LineChartOutlined
+ iconSize="m"
+ css={{ color: theme.colorErrorBorderHover }}
+ />
+ {highlightName(original.name)}
+ {original.description && (
+ <InfoTooltip tooltip={original.description} />
+ )}
+ {pinIcon}
+ </NameLink>
+ ) : (
+ <NameRow>
+ <Icons.LineChartOutlined
+ iconSize="m"
+ css={{ color: theme.colorErrorBorderHover }}
+ />
+ {highlightName(original.name)}
+ {original.description && (
+ <InfoTooltip tooltip={original.description} />
+ )}
+ {pinIcon}
+ </NameRow>
+ );
+ })();
+
+ return (
+ <Dropdown
+ trigger={['contextMenu']}
+ menu={{ items: contextMenuItems }}
+ >
+ <span>
+ {nameContent}
+ {original.folder_path && (
+ <Flex
+ align="center"
+ gap={theme.sizeUnit * 0.5}
+ css={{
+ fontSize: theme.fontSizeXS,
+ color: theme.colorTextSecondary,
+ marginTop: theme.sizeUnit,
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ <Icons.HomeOutlined iconSize="s" />
+ {t('Analytics')}
+ {original.folder_path.map(p => (
+ <Flex
+ key={p.uuid}
+ align="center"
+ gap={theme.sizeUnit * 0.5}
+ >
+ <span css={{ margin: `0 ${theme.sizeUnit * 0.5}px` }}>
+ /
+ </span>
+ <Icons.FolderOutlined iconSize="s" />
+ {p.name}
+ </Flex>
+ ))}
+ </Flex>
+ )}
+ </span>
+ </Dropdown>
+ );
+ },
+ },
+ {
+ accessor: 'type',
+ Header: t('Type'),
+ id: 'type',
+ size: 'sm',
+ Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+ original.type
+ ? original.type.charAt(0).toUpperCase() + original.type.slice(1)
+ : '',
+ },
+ {
+ accessor: 'viz_type',
+ Header: t('Visualization type'),
+ id: 'viz_type',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+ original.viz_type
+ ? (chartRegistry.get(original.viz_type)?.name ?? original.viz_type)
+ : null,
+ },
+ {
+ accessor: 'datasource_name',
+ Header: t('Dataset'),
+ id: 'datasource',
+ disableSortBy: false,
+ Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+ if (!original.datasource_name) return null;
+ if (original.datasource_url) {
+ return (
+ <a href={original.datasource_url}>{original.datasource_name}</a>
+ );
+ }
+ return original.datasource_name;
+ },
+ },
+ {
+ accessor: 'tags',
+ Header: t('Tags'),
+ id: 'tags',
+ disableSortBy: true,
+ hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem),
+ Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+ if (!original.tags?.length) return null;
+ return (
+ <TagsList
+ tags={original.tags.filter(
+ (tag: TagType) =>
+ tag.type === 'TagTypes.custom' ||
+ tag.type === TagTypeEnum.Custom,
+ )}
+ maxTags={3}
+ />
+ );
+ },
+ },
+ {
+ accessor: 'editors',
+ Header: t('Editors'),
+ id: 'editors',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+ <SubjectPile subjects={original.editors || []} />
+ ),
+ },
+ {
+ accessor: 'viewers',
+ Header: t('Viewers'),
+ id: 'viewers',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+ <SubjectPile subjects={original.viewers || []} />
+ ),
+ },
+ {
+ accessor: 'changed_on',
+ Header: t('Last modified'),
+ id: 'changed_on',
+ Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+ original.changed_on_humanized ? (
+ <ModifiedInfo
+ date={original.changed_on_humanized}
+ user={original.changed_by ?? undefined}
+ />
+ ) : (
+ '-'
+ ),
+ },
+ {
+ Header: t('Actions'),
+ id: 'actions',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+ if (!original.type) {
+ return null;
+ }
+ if (original.type === 'folder') {
+ const canEdit = original.user_permission === 'editor';
+ return (
+ <Actions className="actions">
+ {canEdit && !original.is_private && (
+ <Tooltip title={t('Manage permissions')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setFolderToManagePerms(original)}
+ >
+ <Icons.ShareAltOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ {canEdit && !original.is_only_me && (
+ <Tooltip title={t('Edit folder')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setFolderToRename(original)}
+ >
+ <Icons.EditOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ {canEdit &&
+ original.parent_uuid &&
+ original.inherits_permissions === false && (
+ <Tooltip
+ title={t('Sync permissions with parent')}
+ placement="bottom"
+ >
+ <Icons.SyncOutlined
+ iconSize="l"
+ className="action-button"
+ onClick={async () => {
+ try {
+ await SupersetClient.put({
+ endpoint: `/api/v1/folders/${original.uuid}`,
+ jsonPayload: { sync_permissions: true },
+ });
+ addSuccessToast(
+ t('Permissions synced with parent'),
+ );
+ refreshData();
+ } catch {
+ addDangerToast(t('Error syncing permissions'));
+ }
+ }}
+ />
+ </Tooltip>
+ )}
+ {canEdit && !original.is_only_me && (
+ <Tooltip title={t('Delete folder')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setFolderDeleteQueue([original])}
+ >
+ <Icons.DeleteOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </Actions>
+ );
+ }
+ // chart / dashboard rows: mirror the CRUD lists' row actions.
+ const isAssetEditor = original.editors?.some(
+ o => o.user_id === currentUserId,
+ );
+ const canEditAsset = canEditCurrentFolder || isAssetEditor;
+ return (
+ <Actions className="actions">
+ {canEditAsset && (
+ <Tooltip title={t('Edit')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() =>
+ original.type === 'chart'
+ ? openChartEdit(original)
+ : setEditDashboard({
+ id: original.id,
+ title: original.name,
+ })
+ }
+ >
+ <Icons.EditOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ <Tooltip title={t('Export')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => exportAsset(original)}
+ >
+ <Icons.UploadOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ {canEditAsset && (
+ <ConfirmStatusChange
+ title={t('Please confirm')}
+ description={t(
+ 'Are you sure you want to delete %s?',
+ original.name,
+ )}
+ onConfirm={() => deleteAsset(original)}
+ >
+ {confirmDelete => (
+ <Tooltip title={t('Delete')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <Icons.DeleteOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </ConfirmStatusChange>
+ )}
+ </Actions>
+ );
+ },
+ },
+ ],
+ [
+ drillInto,
+ expandedKeys,
+ toggleExpand,
+ openChartEdit,
+ exportAsset,
+ deleteAsset,
+ isPinned,
+ isAtRoot,
+ canEditCurrentFolder,
+ pinnedKeys.size,
+ pinItem,
+ unpinItem,
+ highlightName,
+ theme,
+ ],
+ );
+
+ const filters: ListViewFilters = useMemo(
+ () => [
+ {
+ Header: t('Search'),
+ key: 'search',
+ id: 'name',
+ input: 'search',
+ operator: FilterOperator.Contains,
+ },
+ {
+ Header: t('Type'),
+ key: 'type',
+ id: 'type',
+ input: 'select',
+ operator: FilterOperator.Equals,
+ unfilteredLabel: t('All'),
+ selects: [
+ { label: t('Folder'), value: 'folder' },
+ { label: t('Chart'), value: 'chart' },
+ { label: t('Dashboard'), value: 'dashboard' },
+ ],
+ },
+ {
+ Header: t('Editor'),
+ key: 'editors',
+ id: 'editors',
+ input: 'select',
+ operator: FilterOperator.RelationManyMany,
+ unfilteredLabel: t('All'),
+ fetchSelects: createFetchRelated(
+ 'chart',
+ 'editors',
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('An error occurred while fetching editors: %s', errMsg),
+ ),
+ ),
+ ),
+ paginate: true,
+ },
+ {
+ Header: t('Viewer'),
+ key: 'viewers',
+ id: 'viewers',
+ input: 'select',
+ operator: FilterOperator.RelationManyMany,
+ unfilteredLabel: t('All'),
+ fetchSelects: createFetchRelated(
+ 'chart',
+ 'viewers',
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('An error occurred while fetching viewers: %s', errMsg),
+ ),
+ ),
+ ),
+ paginate: true,
+ },
+ {
+ Header: t('Last modified'),
+ key: 'changed_on',
+ id: 'changed_on',
+ input: 'datetime_range',
+ operator: FilterOperator.Between,
+ dateFilterValueType: 'iso',
+ },
+ {
+ Header: t('Visualization type'),
+ key: 'viz_type',
+ id: 'viz_type',
+ input: 'select',
+ operator: FilterOperator.Equals,
+ unfilteredLabel: t('All'),
+ selects: chartRegistry
+ .keys()
+ .filter(k => nativeFilterGate(chartRegistry.get(k)?.behaviors ?? []))
+ .map(k => ({ label: chartRegistry.get(k)?.name ?? k, value: k }))
+ .sort((a, b) => a.label.localeCompare(b.label)),
+ },
+ {
+ Header: t('Dataset'),
+ key: 'datasource',
+ id: 'datasource',
+ input: 'select',
+ operator: FilterOperator.Equals,
+ unfilteredLabel: t('All'),
+ fetchSelects: createFetchDatasets,
+ paginate: true,
+ },
+ ...(isFeatureEnabled(FeatureFlag.TaggingSystem)
+ ? [
+ {
+ Header: t('Tag'),
+ key: 'tags',
+ id: 'tags',
+ input: 'select' as const,
+ operator: FilterOperator.RelationManyMany,
+ unfilteredLabel: t('All'),
+ fetchSelects: loadTags,
+ },
+ ]
+ : []),
+ ],
+ [addDangerToast],
+ );
+
+ // Pinned-on-top sorting is handled by the backend
+ const sortedItems = items;
+
+ const expandable = useMemo(
+ () => ({
+ // Expansion is driven by the caret in the Name cell, so hide antd's
+ // default expand column and control the open rows ourselves.
+ showExpandColumn: false,
+ expandedRowKeys: expandedKeys,
+ onExpandedRowsChange: (keys: readonly (string | number | bigint)[]) =>
+ setExpandedKeys(keys.map(String)),
+ rowExpandable: (record: Record<string, unknown>) =>
+ record.type === 'dashboard',
+ expandedRowRender: (record: Record<string, unknown>) => {
+ const dashboardId = record.id as number;
+ const entry = chartsByDashboard[dashboardId];
+ return (
+ <DashboardCharts
+ dashboardId={dashboardId}
+ charts={entry?.charts ?? []}
+ loading={entry?.loading ?? true}
+ onRequest={requestDashboardCharts}
+ />
+ );
+ },
+ }),
+ [chartsByDashboard, requestDashboardCharts, expandedKeys],
+ );
+
+ const breadcrumbItems: FolderBreadcrumbItem[] = useMemo(
+ () =>
+ breadcrumb.map((crumb, index) => ({
+ key: crumb.uuid ?? 'root',
+ title:
+ index === 0 ? (
+ <Flex align="center" gap={4}>
+ <Icons.HomeOutlined iconSize="m" />
+ {crumb.name}
+ </Flex>
+ ) : (
+ crumb.name
+ ),
+ hideIcon: index === 0,
+ onClick: () => navigateToCrumb(index),
+ })),
+ [breadcrumb, navigateToCrumb],
+ );
+
+ const menuData: SubMenuProps = {
+ name: t('Analytics'),
+ buttons: [
+ {
+ name: bulkSelectEnabled ? t('Cancel') : t('Bulk select'),
+ buttonStyle: 'secondary',
+ onClick: () => setBulkSelectEnabled(prev => !prev),
+ },
+ {
+ name: t('Move assets'),
+ buttonStyle: 'secondary',
+ onClick: () => setShowTransferModal(true),
+ disabled: !hasFolders,
+ tooltip: !hasFolders
+ ? t('No folders yet. Create one with "New" to move assets.')
+ : undefined,
+ },
+ {
+ name: t('Import'),
+ buttonStyle: 'secondary',
+ component: (
+ <Dropdown
+ trigger={['click']}
+ menu={{
+ items: [
+ {
+ key: 'import-chart',
+ icon: <Icons.LineChartOutlined />,
+ label: t('Chart'),
+ onClick: () => setImportResource('chart'),
+ },
+ {
+ key: 'import-dashboard',
+ icon: <Icons.LayoutOutlined />,
+ label: t('Dashboard'),
+ onClick: () => setImportResource('dashboard'),
+ },
+ ],
+ }}
+ >
+ <Button buttonStyle="secondary">
+ <Icons.DownloadOutlined iconSize="m" /> {t('Import')}{' '}
+ <Icons.DownOutlined iconSize="m" />
+ </Button>
+ </Dropdown>
+ ),
+ },
+ {
+ name: t('New'),
+ buttonStyle: 'primary',
+ component: (
+ <Dropdown
+ trigger={['click']}
+ menu={{
+ items: [
+ {
+ key: 'chart',
+ icon: <Icons.LineChartOutlined />,
+ label: t('Chart'),
+ onClick: () => history.push('/chart/add'),
+ },
+ {
+ key: 'dashboard',
+ icon: <Icons.LayoutOutlined />,
+ label: t('Dashboard'),
+ onClick: () => window.location.assign('/dashboard/new'),
+ },
+ ...(canEditCurrentFolder
+ ? [
+ {
+ key: 'folder',
+ icon: <Icons.FolderOutlined />,
+ label: t('Folder'),
+ onClick: () => setShowCreateFolder(true),
+ },
+ ]
+ : []),
+ ],
+ }}
+ >
+ <Button buttonStyle="primary">
+ <Icons.PlusOutlined iconSize="m" /> {t('New')}{' '}
+ <Icons.DownOutlined iconSize="m" />
+ </Button>
+ </Dropdown>
+ ),
+ } as any,
+ ],
+ };
+
+ // Persistent hover-like highlight on expanded dashboard rows via dynamic CSS,
+ // since antd Table doesn't support styling the parent row of an expanded section.
+ const expandedRowStyle = useMemo(() => {
+ if (!expandedKeys.length) return null;
+ const selectors = expandedKeys
+ .map(key => `.ant-table-row[data-row-key="${key}"] > td`)
+ .join(', ');
+ return (
+ <style>{`${selectors} { background: ${theme.controlItemBgHover} !important; }`}</style>
+ );
+ }, [expandedKeys, theme.controlItemBgHover]);
+
+ return (
+ <>
+ {expandedRowStyle}
+ {showCreateFolder && (
+ <CreateFolderModal
+ show
+ parentFolderUuid={currentFolder.uuid}
+ onHide={() => setShowCreateFolder(false)}
+ onSuccess={refreshData}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {folderToRename && (
+ <EditFolderModal
+ folderUuid={folderToRename.uuid ?? ''}
+ currentName={folderToRename.name}
+ currentDescription={folderToRename.description}
+ show
+ onHide={() => setFolderToRename(null)}
+ onSuccess={refreshData}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {folderToManagePerms && (
+ <FolderPermissionsModal
+ folderUuid={folderToManagePerms.uuid ?? ''}
+ folderName={folderToManagePerms.name}
+ currentUserId={currentUserId}
+ isSubfolder={!!folderToManagePerms.parent_uuid}
+ show
+ onHide={() => {
+ setFolderToManagePerms(null);
+ refreshData();
+ }}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {(moveTarget || showTransferModal) && (
+ <TransferModal
+ currentFolderUuid={currentFolder.uuid}
+ currentFolderName={currentFolder.name}
+ currentFolderIsPrivate={breadcrumb.some(c => c.is_only_me)}
+ preSelectedKeys={
+ moveTarget ? [`${moveTarget.type}-${moveTarget.id}`] : []
+ }
+ show
+ onHide={() => {
+ setMoveTarget(null);
+ setShowTransferModal(false);
+ }}
+ onSuccess={refreshData}
+ />
+ )}
+ {editChart && (
+ <ChartPropertiesModal
+ slice={editChart}
+ show
+ onHide={() => {
+ pendingFolderMove.current = null;
+ setFolderValidationError(null);
+ setEditChart(null);
+ }}
+ onSave={async () => {
+ await executePendingFolderMove();
+ setEditChart(null);
+ refreshData();
+ }}
+ renderExtraFields={({
+ assetId,
+ assetType,
+ accessorCount,
+ }: ExtraFieldsContext) => ({
+ content: (
+ <FolderSelector
+ assetId={assetId}
+ assetType={assetType}
+ accessorCount={accessorCount}
+ onChange={folderUuid => {
+ pendingFolderMove.current = {
+ assetType,
+ assetId,
+ folderUuid,
+ };
+ }}
+ onValidationError={setFolderValidationError}
+ />
+ ),
+ saveDisabled: !!folderValidationError,
+ saveTooltip: folderValidationError ?? undefined,
+ })}
+ />
+ )}
+ {folderDeleteQueue.length > 0 && (
+ <DeleteFolderModal
+ folder={{
+ uuid: folderDeleteQueue[0].uuid ?? '',
+ name: folderDeleteQueue[0].name,
+ asset_count: folderDeleteQueue[0].asset_count ?? 0,
+ children_count: folderDeleteQueue[0].children_count ?? 0,
+ }}
+ show
+ silent={pendingBatchResult !== null || folderDeleteQueue.length > 1}
+ onHide={() =>
+ setFolderDeleteQueue((prev: ContentItem[]) => prev.slice(1))
+ }
+ onSuccess={() => {
+ setFolderDeleteQueue((prev: ContentItem[]) => prev.slice(1));
+ refreshData();
+ }}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {editDashboard && (
+ <DashboardPropertiesModal
+ dashboardId={editDashboard.id}
+ dashboardTitle={editDashboard.title}
+ show
+ onHide={() => {
+ pendingFolderMove.current = null;
+ setFolderValidationError(null);
+ setEditDashboard(null);
+ }}
+ onSubmit={async () => {
+ await executePendingFolderMove();
+ setEditDashboard(null);
+ refreshData();
+ }}
+ renderExtraFields={({
+ assetId,
+ assetType,
+ accessorCount,
+ }: ExtraFieldsContext) => ({
+ content: (
+ <FolderSelector
+ assetId={assetId}
+ assetType={assetType}
+ accessorCount={accessorCount}
+ onChange={folderUuid => {
+ pendingFolderMove.current = {
+ assetType,
+ assetId,
+ folderUuid,
+ };
+ }}
+ onValidationError={setFolderValidationError}
+ />
+ ),
+ saveDisabled: !!folderValidationError,
+ saveTooltip: folderValidationError ?? undefined,
+ })}
+ />
+ )}
+ {importResource && (
+ <ImportModelsModal
+ resourceName={importResource}
+ resourceLabel={
+ importResource === 'chart' ? t('chart') : t('dashboard')
+ }
+ passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
+ confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ onModelImport={() => {
+ setImportResource(null);
+ refreshData();
+ addSuccessToast(
+ importResource === 'chart'
+ ? t('Chart imported')
+ : t('Dashboard imported'),
+ );
+ }}
+ show
+ onHide={() => setImportResource(null)}
+ passwordFields={passwordFields}
+ setPasswordFields={setPasswordFields}
+ sshTunnelPasswordFields={sshTunnelPasswordFields}
+ setSSHTunnelPasswordFields={setSSHTunnelPasswordFields}
+ sshTunnelPrivateKeyFields={sshTunnelPrivateKeyFields}
+ setSSHTunnelPrivateKeyFields={setSSHTunnelPrivateKeyFields}
+ sshTunnelPrivateKeyPasswordFields={sshTunnelPrivateKeyPasswordFields}
+ setSSHTunnelPrivateKeyPasswordFields={
+ setSSHTunnelPrivateKeyPasswordFields
+ }
+ />
+ )}
+ {!breadcrumbLoading && <SubMenu {...menuData} />}
+ {!breadcrumbLoading && (
+ <ConfirmStatusChange
+ title={t('Please confirm')}
+ description={t('Are you sure you want to delete the selected items?')}
+ onConfirm={handleBulkDelete}
+ >
+ {confirmDelete => (
+ <ListView<ContentItem>
+ key={`${currentFolder.uuid ?? 'root'}-${refreshKey}`}
+ className="analytics-list-view"
+ columns={columns}
+ count={count}
+ data={sortedItems}
+ fetchData={fetchData}
+ refreshData={refreshData}
+ loading={loading}
+ initialSort={[{ id: 'changed_on', desc: true }]}
+ pageSize={PAGE_SIZE}
+ filters={filters}
+ expandable={expandable}
+ defaultViewMode="table"
+ bulkSelectEnabled={bulkSelectEnabled}
+ disableBulkSelect={() => setBulkSelectEnabled(false)}
+ headerContent={
+ isAtRoot ? null : (
+ <>
+ <BreadcrumbWrap>
+ <FolderBreadcrumb items={breadcrumbItems} />
+ </BreadcrumbWrap>
+ {currentFolder.is_only_me && (
+ <Alert
+ type="success"
+ showIcon
+ message={
+ <>
+ <span style={{ fontWeight: 800 }}>
+ {t("Only you can see what's in this folder.")}
+ </span>{' '}
+ {t(
+ 'New charts and dashboards land here by default. Move them out to share. Permissions then inherit from the destination folder.',
+ )}
+ </>
+ }
+ css={{
+ marginBottom: theme.sizeUnit * 2,
+ padding: `${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px`,
+ }}
+ />
+ )}
+ </>
+ )
+ }
+ bulkActions={[
+ ...(canEditCurrentFolder
+ ? [
+ {
+ key: 'delete',
+ name: t('Delete'),
+ type: 'danger' as const,
+ onSelect: confirmDelete,
+ },
+ ]
+ : []),
+ {
+ key: 'export',
+ name: (
+ <Tooltip
+ title={t(
+ 'Export is only available for dashboards and charts',
+ )}
+ >
+ <Flex gap={theme.sizeUnit}>
+ <Icons.WarningOutlined iconSize="s" />
+ {t('Export')}
+ </Flex>
+ </Tooltip>
+ ),
+ type: 'primary',
+ onSelect: handleBulkExport,
+ hidden: (rows: ContentItem[]) =>
+ rows.every(r => r.type === 'folder'),
+ },
+ ]}
+ addSuccessToast={addSuccessToast}
+ addDangerToast={addDangerToast}
+ />
+ )}
+ </ConfirmStatusChange>
+ )}
+ </>
+ );
+}
+
+export default withToasts(AnalyticsList);
diff --git a/superset-frontend/src/pages/AnalyticsList/styles.ts b/superset-frontend/src/pages/AnalyticsList/styles.ts
new file mode 100644
index 00000000000..4e00ac34cd5
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/styles.ts
@@ -0,0 +1,25 @@
+/**
+ * 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, css } from '@apache-superset/core/theme';
+
+export const ModalContent = styled.div`
+ ${({ theme }) => css`
+ padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
+ `}
+`;
diff --git a/superset-frontend/src/pages/AnalyticsList/types.ts b/superset-frontend/src/pages/AnalyticsList/types.ts
new file mode 100644
index 00000000000..fee77945cc6
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/types.ts
@@ -0,0 +1,62 @@
+/**
+ * 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 { TagType } from 'src/components';
+
+export type ItemType = 'folder' | 'chart' | 'dashboard';
+
+export interface User {
+ id: number;
+ first_name?: string;
+ last_name?: string;
+}
+
+export interface AssetEditor {
+ id: number;
+ label: string;
+ type: number;
+ /** Set for user subjects; null for roles and groups. */
+ user_id?: number | null;
+}
+
+/** A contents row (folder or asset) from /api/v1/folders/[<uuid>/]assets. */
+export interface ContentItem {
+ type: ItemType;
+ id: number;
+ uuid: string | null;
+ name: string;
+ description?: string | null;
+ url?: string | null;
+ viz_type?: string | null;
+ datasource_name?: string | null;
+ datasource_url?: string | null;
+ editors?: AssetEditor[];
+ viewers?: AssetEditor[];
+ changed_on?: string;
+ changed_on_humanized?: string | null;
+ changed_by?: User | null;
+ tags?: TagType[];
+ asset_count?: number;
+ children_count?: number;
+ user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+ parent_uuid?: string | null;
+ inherits_permissions?: boolean;
+ is_private?: boolean;
+ is_only_me?: boolean;
+ folder_path?: Array<{ uuid: string; name: string }>;
+}
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx
new file mode 100644
index 00000000000..3b9c4f1e578
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx
@@ -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.',
+ },
+ },
+};
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx
new file mode 100644
index 00000000000..3d61dbf0042
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx
@@ -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');
+});
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.tsx
new file mode 100644
index 00000000000..48aeb52f48c
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.tsx
@@ -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}
+ />
+ );
+}
diff --git a/superset-frontend/src/pages/components/FolderList.stories.tsx b/superset-frontend/src/pages/components/FolderList.stories.tsx
new file mode 100644
index 00000000000..bc6b1ccb9af
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.stories.tsx
@@ -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.',
+ },
+ },
+};
diff --git a/superset-frontend/src/pages/components/FolderList.test.tsx b/superset-frontend/src/pages/components/FolderList.test.tsx
new file mode 100644
index 00000000000..f681016fa63
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.test.tsx
@@ -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]);
+});
diff --git a/superset-frontend/src/pages/components/FolderList.tsx b/superset-frontend/src/pages/components/FolderList.tsx
new file mode 100644
index 00000000000..128f6eb4fcb
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.tsx
@@ -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 '@superset-ui/core/components';
+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}
+ />
+ );
+}
diff --git a/superset-frontend/src/pages/components/assetIcons.tsx b/superset-frontend/src/pages/components/assetIcons.tsx
new file mode 100644
index 00000000000..82625678a33
--- /dev/null
+++ b/superset-frontend/src/pages/components/assetIcons.tsx
@@ -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 '@superset-ui/core/components/Icons';
+import type { IconType } from '@superset-ui/core/components/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,
+};
diff --git a/superset-frontend/src/pages/components/index.ts b/superset-frontend/src/pages/components/index.ts
new file mode 100644
index 00000000000..1b8e1bab787
--- /dev/null
+++ b/superset-frontend/src/pages/components/index.ts
@@ -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';
diff --git a/superset-frontend/src/pages/components/types.ts b/superset-frontend/src/pages/components/types.ts
new file mode 100644
index 00000000000..09b85e91e2e
--- /dev/null
+++ b/superset-frontend/src/pages/components/types.ts
@@ -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;
+}
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 94e99e197d9..7d1455d504f 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -37,6 +37,11 @@ const ChartCreation = lazy(
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
);
+const AnalyticsList = lazy(
+ () =>
+ import(/* webpackChunkName: "AnalyticsList" */ 'src/pages/AnalyticsList'),
+);
+
const AnnotationLayerList = lazy(
() =>
import(
@@ -266,6 +271,13 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
routes.push({ path: RoutePaths.TAGS, Component: Tags });
}
+if (isFeatureEnabled('FOLDERS' as FeatureFlag)) {
+ routes.push({
+ path: '/analytics/:folderUuid?/',
+ Component: AnalyticsList,
+ });
+}
+
// Recently-Archived view — gated by the soft-delete feature (T007).
if (isFeatureEnabled(FeatureFlag.SoftDelete)) {
routes.push({
diff --git a/superset/commands/folder/__init__.py b/superset/commands/folder/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/commands/folder/__init__.py
@@ -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.
diff --git a/superset/commands/folder/assets.py b/superset/commands/folder/assets.py
new file mode 100644
index 00000000000..d833d14a797
--- /dev/null
+++ b/superset/commands/folder/assets.py
@@ -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)
diff --git a/superset/commands/folder/create.py b/superset/commands/folder/create.py
new file mode 100644
index 00000000000..d54661dd436
--- /dev/null
+++ b/superset/commands/folder/create.py
@@ -0,0 +1,102 @@
+# 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,
+ FolderForbiddenError,
+ 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())
+ elif not security_manager.is_admin():
+ from superset.utils.core import get_user_id
+
+ user_id = get_user_id()
+ if not FolderPermissionDAO.user_is_folder_editor(
+ user_id, self._parent.id
+ ):
+ raise FolderForbiddenError()
+
+ 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)
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
new file mode 100644
index 00000000000..70e32e52fad
--- /dev/null
+++ b/superset/commands/folder/delete.py
@@ -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()
diff --git a/superset/commands/folder/exceptions.py b/superset/commands/folder/exceptions.py
new file mode 100644
index 00000000000..a2f3593370e
--- /dev/null
+++ b/superset/commands/folder/exceptions.py
@@ -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",
+ )
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
new file mode 100644
index 00000000000..1b6fd909a0e
--- /dev/null
+++ b/superset/commands/folder/update.py
@@ -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)
diff --git a/superset/config.py b/superset/config.py
index 6886ee48df8..aa99a627ef2 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -693,6 +693,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Allow metrics and columns to be grouped into folders in the chart builder
# @lifecycle: development
"DATASET_FOLDERS": False,
+ # Enable the folder-based analytics view (replaces Charts/Dashboards nav)
+ # @lifecycle: development
+ "FOLDERS": False,
# Enable support for date range timeshifts (e.g., "2015-01-03 : 2015-01-04")
# in addition to relative timeshifts (e.g., "1 day ago")
# @lifecycle: development
@@ -3229,6 +3232,10 @@ EXTRA_ASSET_EXPORT_FIELDS: Callable[[Any, str], dict[str, Any]] | None = None
# Consume the "extra" mapping when importing a chart or dashboard. Receives
# (model, asset_type, extra) once the asset exists.
EXTRA_ASSET_IMPORT_HANDLER: Callable[[Any, str, dict[str, Any]], 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
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
new file mode 100644
index 00000000000..a1080c7205e
--- /dev/null
+++ b/superset/daos/folder.py
@@ -0,0 +1,1030 @@
+# 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 ``superset.commands.folder``; 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,
+ editors: list[int] | None = None,
+ viewers: 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()
+
+ # Escape LIKE wildcards in search input
+ if search:
+ search = (
+ search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ )
+
+ # When searching, find ALL descendant folders via recursive CTE.
+ child_folder_ids_sub = None
+ if search:
+ base = (
+ select(Folder.id)
+ .where(
+ Folder.parent_id == folder_id
+ if folder_id is not None
+ else Folder.parent_id.is_(None),
+ Folder.folder_type == folder_type,
+ )
+ .cte(recursive=True, name="descendants")
+ )
+ descendants = base.union_all(
+ select(Folder.id).where(
+ Folder.parent_id == base.c.id,
+ Folder.folder_type == folder_type,
+ )
+ )
+ child_folder_ids_sub = select(descendants.c.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)
+ if search and child_folder_ids_sub is not None:
+ fq = fq.where(
+ or_(
+ Folder.parent_id == folder_id
+ if folder_id is not None
+ else Folder.parent_id.is_(None),
+ Folder.parent_id.in_(child_folder_ids_sub),
+ )
+ )
+ fq = fq.where(Folder.name.ilike(f"%{search}%", escape="\\"))
+ else:
+ fq = fq.where(
+ Folder.parent_id.is_(None)
+ if folder_id is None
+ else Folder.parent_id == folder_id
+ )
+ 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 see member folders + implicit folders
+ if not is_admin and user_id:
+ from superset.folders.utils import user_accessible_folder_ids
+
+ member_folder_ids = user_accessible_folder_ids(user_id)
+ implicit_ids = cls.folders_with_accessible_assets(user_id, folder_type)
+ if implicit_ids:
+ fq = fq.where(
+ or_(
+ Folder.id.in_(member_folder_ids),
+ Folder.id.in_(list(implicit_ids)),
+ )
+ )
+ else:
+ fq = fq.where(Folder.id.in_(member_folder_ids))
+ if editors:
+ editor_folder_ids = select(folder_editors.c.folder_id).where(
+ folder_editors.c.user_id.in_(editors)
+ )
+ fq = fq.where(Folder.id.in_(editor_folder_ids))
+ if viewers:
+ viewer_folder_ids = select(folder_viewers.c.folder_id).where(
+ folder_viewers.c.user_id.in_(viewers)
+ )
+ fq = fq.where(Folder.id.in_(viewer_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:
+ if search and child_folder_ids_sub is not None:
+ used = select(fk_col).where(fk_col.isnot(None))
+ in_child = select(fk_col).where(
+ FolderObject.folder_id.in_(child_folder_ids_sub),
+ fk_col.isnot(None),
+ )
+ aq = aq.where(or_(model.id.notin_(used), model.id.in_(in_child)))
+ else:
+ used = select(fk_col).where(fk_col.isnot(None))
+ aq = aq.where(model.id.notin_(used))
+ else:
+ if search and child_folder_ids_sub is not None:
+ in_folder = select(fk_col).where(
+ or_(
+ FolderObject.folder_id == folder_id,
+ FolderObject.folder_id.in_(child_folder_ids_sub),
+ ),
+ fk_col.isnot(None),
+ )
+ 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}%", escape="\\"))
+ if modified_start:
+ aq = aq.where(model.changed_on >= modified_start)
+ if modified_end:
+ aq = aq.where(model.changed_on <= modified_end)
+ if owners:
+ from superset.subjects.models import Subject
+
+ aq = aq.where(model.editors.any(Subject.user_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))
+ if editors:
+ fk_col_filter = getattr(
+ FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+ )
+ editor_asset_ids = select(fk_col_filter).where(
+ FolderObject.folder_id.in_(
+ select(folder_editors.c.folder_id).where(
+ folder_editors.c.user_id.in_(editors)
+ )
+ ),
+ fk_col_filter.isnot(None),
+ )
+ aq = aq.where(model.id.in_(editor_asset_ids))
+ if viewers:
+ fk_col_filter = getattr(
+ FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+ )
+ viewer_asset_ids = select(fk_col_filter).where(
+ FolderObject.folder_id.in_(
+ select(folder_viewers.c.folder_id).where(
+ folder_viewers.c.user_id.in_(viewers)
+ )
+ ),
+ fk_col_filter.isnot(None),
+ )
+ aq = aq.where(model.id.in_(viewer_asset_ids))
+
+ # 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 is an editor of the asset
+ from superset.subjects.models import Subject
+
+ access_conditions.append(
+ model.editors.any(Subject.user_id == user_id)
+ )
+
+ # 2. User has folder membership for the asset
+ from superset.folders.utils import user_accessible_folder_ids
+
+ user_folder_ids = user_accessible_folder_ids(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
+
+ @classmethod
+ def get_folder_path(cls, folder_id: int) -> list[dict[str, str]]:
+ """Build the breadcrumb path from root to the given folder."""
+ path: list[dict[str, str]] = []
+ folder = db.session.get(Folder, folder_id)
+ while folder:
+ path.append({"uuid": str(folder.uuid), "name": folder.name})
+ folder = folder.parent
+ path.reverse()
+ return path
+
+ @classmethod
+ def get_asset_folder_paths(
+ cls, asset_ids: dict[str, list[int]]
+ ) -> dict[tuple[str, int], list[dict[str, str]]]:
+ """Get folder paths for a batch of assets. Returns {(type, id): path}."""
+ result: dict[tuple[str, int], list[dict[str, str]]] = {}
+ path_cache: dict[int, list[dict[str, str]]] = {}
+ for asset_type, ids in asset_ids.items():
+ config = ASSET_TYPE_CONFIGS.get(asset_type)
+ if not config:
+ continue
+ fk_col = getattr(FolderObject, config.fk_column)
+ links = (
+ db.session.query(FolderObject.folder_id, fk_col)
+ .filter(fk_col.in_(ids), fk_col.isnot(None))
+ .all()
+ )
+ for folder_id, asset_id in links:
+ if folder_id not in path_cache:
+ path_cache[folder_id] = cls.get_folder_path(folder_id)
+ result[(asset_type, asset_id)] = path_cache[folder_id]
+ return result
+
+ @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
+ )
+ # Clear the in-memory collection so db.session.delete(folder) below
+ # doesn't re-null the children's parent_id.
+ folder.children.clear()
+ 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]]:
+ from superset import security_manager
+
+ subjects: list[dict[str, Any]] = []
+ admin_role = security_manager.find_role("Admin")
+
+ 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 {}
+ )
+
+ def _is_admin(user: User | None) -> bool:
+ if not user or not admin_role:
+ return False
+ return admin_role in user.roles
+
+ for row in editors:
+ user = users_by_id.get(row.user_id)
+ is_admin = _is_admin(user)
+ subjects.append(
+ {
+ "user_id": row.user_id,
+ "permission": "admin" if is_admin else "editor",
+ "email": user.email if user else None,
+ "is_admin": is_admin,
+ }
+ )
+
+ for row in viewers:
+ user = users_by_id.get(row.user_id)
+ is_admin = _is_admin(user)
+ subjects.append(
+ {
+ "user_id": row.user_id,
+ "permission": "admin" if is_admin else "viewer",
+ "email": user.email if user else None,
+ "is_admin": is_admin,
+ }
+ )
+
+ 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:
+ 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 not existing:
+ raise ValueError(f"User {user_id} is not a member of this folder")
+ 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 folders_with_accessible_assets(cls, user_id: int, folder_type: str) -> set[int]:
+ """Return folder IDs containing at least one asset the user owns.
+
+ Only checks ownership — not datasource access. This is intentional:
+ the folder gate in raise_for_access blocks non-members from accessing
+ foldered assets even if they have datasource access. So implicit
+ folder visibility should only show folders with assets the user owns,
+ since those are the only assets the gate allows through.
+ """
+ folder_ids: set[int] = set()
+
+ for name in asset_types_for_folder_type(folder_type):
+ config = ASSET_TYPE_CONFIGS[name]
+ model = config.model
+ fk_col = getattr(FolderObject, config.fk_column)
+
+ from superset.subjects.models import Subject
+
+ owned_assets = (
+ select(model.id)
+ .where(model.editors.any(Subject.user_id == user_id))
+ .subquery()
+ )
+ rows = (
+ db.session.query(FolderObject.folder_id)
+ .join(Folder, Folder.id == FolderObject.folder_id)
+ .filter(
+ fk_col.isnot(None),
+ fk_col.in_(select(owned_assets.c.id)),
+ Folder.folder_type == folder_type,
+ )
+ .distinct()
+ .all()
+ )
+ folder_ids.update(r[0] for r in rows)
+
+ # Include ancestor folders so the tree stays navigable.
+ if folder_ids:
+ parent_map = {
+ f.id: f.parent_id
+ for f in db.session.query(Folder.id, Folder.parent_id)
+ .filter(Folder.folder_type == folder_type)
+ .all()
+ }
+ ancestors: set[int] = set()
+ for fid in folder_ids:
+ parent_id = parent_map.get(fid)
+ while parent_id is not None and parent_id not in ancestors:
+ ancestors.add(parent_id)
+ parent_id = parent_map.get(parent_id)
+ folder_ids |= ancestors
+
+ return folder_ids
+
+ @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()
diff --git a/superset/daos/folder_permissions.py b/superset/daos/folder_permissions.py
new file mode 100644
index 00000000000..1342c6fdbcc
--- /dev/null
+++ b/superset/daos/folder_permissions.py
@@ -0,0 +1,457 @@
+# 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 superset.folders.utils import get_folder_editor_users
+
+ 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 []
+
+ return [
+ {
+ "id": u.id,
+ "first_name": u.first_name,
+ "last_name": u.last_name,
+ }
+ for u in get_folder_editor_users(fo.folder_id)
+ ]
diff --git a/superset/folders/__init__.py b/superset/folders/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/folders/__init__.py
@@ -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.
diff --git a/superset/folders/api.py b/superset/folders/api.py
new file mode 100644
index 00000000000..803b867c1aa
--- /dev/null
+++ b/superset/folders/api.py
@@ -0,0 +1,1558 @@
+# 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.
+"""REST API for folder management and asset assignment."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from typing import Any
+
+from flask import g, request, Response
+from flask_appbuilder.api import (
+ expose,
+ permission_name,
+ protect,
+ rison as parse_rison,
+ safe,
+)
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset import security_manager
+from superset.commands.folder.assets import (
+ AddFolderAssetsCommand,
+ UpdateFolderAssetsCommand,
+)
+from superset.commands.folder.create import CreateFolderCommand
+from superset.commands.folder.delete import DeleteFolderCommand
+from superset.commands.folder.exceptions import (
+ FolderCreateFailedError,
+ FolderDeleteFailedError,
+ FolderForbiddenError,
+ FolderInvalidError,
+ FolderNotFoundError,
+ FolderUpdateFailedError,
+)
+from superset.commands.folder.update import UpdateFolderCommand
+from superset.daos.folder import FolderDAO, ResolvedAsset
+from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.extensions import db, event_logger
+from superset.folders.constants import ASSET_TYPE_CONFIGS, DEFAULT_FOLDER_TYPE
+from superset.folders.models import Folder, folder_editors, folder_viewers
+from superset.folders.schemas import (
+ FolderAssetSchema,
+ FolderAssetsPutSchema,
+ FolderContentItemSchema,
+ FolderContentsResponseSchema,
+ FolderListResponseSchema,
+ FolderPinPostSchema,
+ FolderPostSchema,
+ FolderPutSchema,
+ FolderResponseSchema,
+ FolderRootResponseSchema,
+ FolderSchema,
+ FolderSubjectPostSchema,
+ FolderSubjectPutSchema,
+)
+from superset.folders.utils import can_manage_folders
+from superset.utils import json as json_utils
+from superset.utils.core import get_user_id
+from superset.utils.decorators import transaction
+from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
+
+logger = logging.getLogger(__name__)
+
+
+def _serialize_user(user: Any) -> dict[str, Any] | None:
+ if not user:
+ return None
+ return {
+ "id": user.id,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ }
+
+
+def _serialize_subjects(subjects: Any) -> list[dict[str, Any]]:
+ return [
+ {"id": s.id, "label": s.label, "type": s.type, "user_id": s.user_id}
+ for s in (subjects or [])
+ ]
+
+
+def _get_user_permission(
+ folder: Folder, implicit_folder_ids: set[int] | None = None
+) -> str | None:
+ """Return the current user's permission level for this folder."""
+ if security_manager.is_admin():
+ return "editor"
+ user_id = get_user_id()
+ if not user_id:
+ return None
+ if FolderPermissionDAO.user_is_folder_editor(user_id, folder.id):
+ return "editor"
+ if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
+ return "viewer"
+ if implicit_folder_ids and folder.id in implicit_folder_ids:
+ return "implicit"
+ return None
+
+
+def _is_only_me(folder: Folder) -> bool:
+ """Return whether this is the system 'Only Me' private folder."""
+ if not folder.is_private or not folder.extra:
+ return False
+ extra = json_utils.loads(folder.extra)
+ return bool(extra.get("only_me"))
+
+
+def _get_inherits_permissions(folder: Folder) -> bool:
+ """Return whether this folder inherits permissions from its parent."""
+ if not folder.extra:
+ return True
+ extra = json_utils.loads(folder.extra)
+ return extra.get("inherits_permissions", True)
+
+
+def serialize_folder(
+ folder: Folder,
+ children_count: int | None = None,
+ asset_count: int | None = None,
+ implicit_folder_ids: set[int] | None = None,
+) -> dict[str, Any]:
+ """Serialize a folder's metadata for API responses.
+
+ Accepts optional pre-computed counts to avoid N+1 queries. When not
+ provided, falls back to relationship access (acceptable for single-folder
+ responses but expensive in list views).
+ """
+ return {
+ "type": "folder",
+ "id": folder.id,
+ "uuid": str(folder.uuid),
+ "name": folder.name,
+ "description": folder.description,
+ "parent_uuid": str(folder.parent.uuid) if folder.parent else None,
+ "folder_type": folder.folder_type,
+ "is_private": folder.is_private,
+ "children_count": (
+ children_count if children_count is not None else len(folder.children)
+ ),
+ "asset_count": (
+ asset_count if asset_count is not None else len(folder.objects)
+ ),
+ "created_on": folder.created_on,
+ "changed_on": folder.changed_on,
+ "changed_on_humanized": folder.changed_on_humanized,
+ "created_by": _serialize_user(folder.created_by),
+ "changed_by": _serialize_user(folder.changed_by),
+ "user_permission": _get_user_permission(folder, implicit_folder_ids),
+ "inherits_permissions": _get_inherits_permissions(folder),
+ "is_only_me": _is_only_me(folder),
+ "owners": [],
+ }
+
+
+def serialize_row(
+ kind: str,
+ obj: Any,
+ folder_path: list[dict[str, str]] | None = None,
+ implicit_folder_ids: set[int] | None = None,
+) -> dict[str, Any]:
+ """Serialize a contents row (folder or asset) into a uniform-ish item."""
+ data = (
+ serialize_folder(obj, implicit_folder_ids=implicit_folder_ids)
+ if kind == "folder"
+ else serialize_asset(kind, obj)
+ )
+ if folder_path is not None:
+ data["folder_path"] = folder_path
+ return data
+
+
+def serialize_rows_with_paths(
+ rows: list[ResolvedAsset],
+ current_folder_id: int | None,
+) -> list[dict[str, Any]]:
+ """Serialize rows and attach folder_path for items from child folders."""
+ # Batch-resolve folder paths for assets
+ asset_ids: dict[str, list[int]] = {}
+ for kind, obj in rows:
+ if kind != "folder":
+ asset_ids.setdefault(kind, []).append(obj.id)
+ asset_paths = FolderDAO.get_asset_folder_paths(asset_ids) if asset_ids else {}
+
+ # Resolve current folder UUID once, not per row
+ current_uuid = None
+ if current_folder_id is not None:
+ current_folder = db.session.get(Folder, current_folder_id)
+ current_uuid = str(current_folder.uuid) if current_folder else None
+
+ result = []
+ for kind, obj in rows:
+ path = None
+ if kind == "folder" and obj.parent_id != current_folder_id:
+ # Folder from a deeper level — show path to its parent (not itself)
+ path = FolderDAO.get_folder_path(obj.parent_id) if obj.parent_id else None
+ elif kind != "folder":
+ full_path = asset_paths.get((kind, obj.id))
+ if full_path:
+ asset_folder_uuid = full_path[-1]["uuid"] if full_path else None
+ if asset_folder_uuid != current_uuid:
+ path = full_path
+ result.append(serialize_row(kind, obj, path))
+ return result
+
+
+def serialize_asset(asset_type: str, asset: Any) -> dict[str, Any]:
+ """Serialize a single asset (dashboard/chart/dataset) with column data."""
+ config = ASSET_TYPE_CONFIGS[asset_type]
+ return {
+ "type": asset_type,
+ "id": asset.id,
+ "uuid": str(asset.uuid) if getattr(asset, "uuid", None) else None,
+ "name": getattr(asset, config.title_attr, None),
+ "url": getattr(asset, "url", None),
+ "changed_on": getattr(asset, "changed_on", None),
+ "changed_on_humanized": getattr(asset, "changed_on_humanized", None),
+ "changed_by": _serialize_user(getattr(asset, "changed_by", None)),
+ "editors": _serialize_subjects(getattr(asset, "editors", [])),
+ "viewers": _serialize_subjects(getattr(asset, "viewers", [])),
+ # Chart-only columns; ``None`` for other asset kinds.
+ "viz_type": getattr(asset, "viz_type", None),
+ "datasource_name": (
+ asset.datasource_name_text()
+ if asset_type == "chart"
+ and callable(getattr(asset, "datasource_name_text", None))
+ else None
+ ),
+ "datasource_url": (
+ asset.datasource_url()
+ if asset_type == "chart"
+ and callable(getattr(asset, "datasource_url", None))
+ else None
+ ),
+ "tags": [
+ {
+ "id": t.id,
+ "name": t.name,
+ "type": t.type.value if hasattr(t.type, "value") else t.type,
+ }
+ for t in getattr(asset, "tags", []) or []
+ ],
+ }
+
+
+def serialize_assets(assets: list[ResolvedAsset]) -> list[dict[str, Any]]:
+ """Serialize assets, most-recently-changed first."""
+ serialized = [serialize_asset(asset_type, asset) for asset_type, asset in assets]
+ serialized.sort(key=lambda item: item["changed_on"] or datetime.min, reverse=True)
+ return serialized
+
+
+def _positive_int(key: str, default: int, maximum: int = 100) -> int:
+ """Parse a non-negative integer query param, clamped to ``maximum``."""
+ raw = request.args.get(key)
+ if raw is not None and raw.isdigit():
+ return min(int(raw), maximum)
+ return default
+
+
+def _parse_rison_args(rison_args: dict[str, Any]) -> dict[str, Any]:
+ """Convert rison-encoded query params into ``get_contents()`` kwargs.
+
+ Expected rison format (matches ``useListViewResource``)::
+
+ {
+ "order_column": "changed_on",
+ "order_direction": "desc",
+ "page": 0,
+ "page_size": 25,
+ "filters": [
+ {"col": "name", "opr": "ct", "value": "sales"},
+ {"col": "type", "opr": "in", "value": ["chart", "dashboard"]},
+ {"col": "editors", "opr": "rel_m_m", "value": 1},
+ {"col": "changed_on", "opr": "gt", "value": "2025-01-01"},
+ {"col": "changed_on", "opr": "lt", "value": "2025-12-31"},
+ {"col": "viz_type", "opr": "in", "value": ["pie", "bar"]},
+ {"col": "dataset", "opr": "in", "value": [1, 2, 3]},
+ ]
+ }
+ """
+ result: dict[str, Any] = {
+ "search": None,
+ "types": None,
+ "viz_types": None,
+ "datasets": None,
+ "tags": None,
+ "editors": None,
+ "viewers": None,
+ "modified_start": None,
+ "modified_end": None,
+ "page": min(int(rison_args.get("page", 0)), 10000),
+ "page_size": min(int(rison_args.get("page_size", 25)), 100),
+ "sort_column": rison_args.get("order_column", "changed_on"),
+ "sort_order": rison_args.get("order_direction", "desc"),
+ }
+ for f in rison_args.get("filters", []):
+ col = f.get("col")
+ opr = f.get("opr")
+ val = f.get("value")
+ if val is None or val == "":
+ continue
+ if col == "name" and opr == "ct":
+ result["search"] = str(val)[:255]
+ elif col == "type" and opr == "in":
+ result["types"] = val if isinstance(val, list) else [val]
+ elif col == "editors" and opr == "rel_m_m":
+ editors = result["editors"] or []
+ editors.append(int(val))
+ result["editors"] = editors
+ elif col == "viewers" and opr == "rel_m_m":
+ viewers = result["viewers"] or []
+ viewers.append(int(val))
+ result["viewers"] = viewers
+ elif col == "changed_on":
+ try:
+ dt = datetime.fromisoformat(str(val))
+ except ValueError:
+ continue
+ if opr == "gt":
+ result["modified_start"] = dt
+ elif opr == "lt":
+ result["modified_end"] = dt
+ elif col == "viz_type" and opr == "in":
+ result["viz_types"] = val if isinstance(val, list) else [val]
+ elif col == "dataset" and opr == "in":
+ result["datasets"] = [
+ int(v) for v in (val if isinstance(val, list) else [val])
+ ]
+ elif col == "tags" and opr == "rel_m_m":
+ result["tags"] = [int(v) for v in (val if isinstance(val, list) else [val])]
+ return result
+
+
+class FolderRestApi(BaseSupersetApi):
+ """CRUD and asset assignment for folders."""
+
+ resource_name = "folders"
+ class_permission_name = "Folder"
+ allow_browser_login = True
+ openapi_spec_tag = "Folders"
+ openapi_spec_component_schemas = (
+ FolderSchema,
+ FolderAssetSchema,
+ FolderPostSchema,
+ FolderPutSchema,
+ FolderAssetsPutSchema,
+ FolderListResponseSchema,
+ FolderResponseSchema,
+ FolderContentItemSchema,
+ FolderContentsResponseSchema,
+ FolderRootResponseSchema,
+ )
+
+ add_model_schema = FolderPostSchema()
+ edit_model_schema = FolderPutSchema()
+ assets_model_schema = FolderAssetsPutSchema()
+
+ @staticmethod
+ def _raise_for_folder_access(folder: Folder) -> None:
+ """Raise FolderForbiddenError if user cannot view the folder.
+
+ Allows explicit members (editors/viewers) and implicit access
+ (users who own at least one asset inside the folder).
+ """
+ if security_manager.is_admin():
+ return
+ user_id = get_user_id()
+ if not user_id:
+ raise FolderForbiddenError()
+ if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
+ return
+ implicit_ids = FolderDAO.folders_with_accessible_assets(
+ user_id, folder.folder_type
+ )
+ if folder.id not in implicit_ids:
+ raise FolderForbiddenError()
+
+ @staticmethod
+ def _raise_for_folder_edit(folder: Folder) -> None:
+ """Raise FolderForbiddenError if user cannot edit the folder."""
+
+ if security_manager.is_admin():
+ return
+ user_id = get_user_id()
+ if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+ user_id, folder.id
+ ):
+ raise FolderForbiddenError()
+
+ @expose("/", methods=("GET",))
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_list",
+ log_to_statsd=False,
+ )
+ def get_list(self) -> Response:
+ """List folders, optionally filtered by folder type.
+ ---
+ get:
+ summary: List folders
+ parameters:
+ - in: query
+ name: folder_type
+ schema:
+ type: string
+ description: Only return folders of this type.
+ responses:
+ 200:
+ description: A list of folders
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderListResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ folder_type = request.args.get("folder_type")
+ folders = FolderDAO.get_folders(folder_type=folder_type)
+
+ # Non-admins see explicit member folders + implicit access folders
+ # Private folders are only visible to their own members
+ implicit_folder_ids: set[int] = set()
+ user_id = get_user_id()
+ if not security_manager.is_admin():
+ if user_id:
+ # Batch query instead of N+1 per-folder checks
+ all_folder_ids = [f.id for f in folders]
+ editor_ids = (
+ {
+ r[0]
+ for r in db.session.query(folder_editors.c.folder_id)
+ .filter(
+ folder_editors.c.user_id == user_id,
+ folder_editors.c.folder_id.in_(all_folder_ids),
+ )
+ .all()
+ }
+ if all_folder_ids
+ else set()
+ )
+ viewer_ids = (
+ {
+ r[0]
+ for r in db.session.query(folder_viewers.c.folder_id)
+ .filter(
+ folder_viewers.c.user_id == user_id,
+ folder_viewers.c.folder_id.in_(all_folder_ids),
+ )
+ .all()
+ }
+ if all_folder_ids
+ else set()
+ )
+ member_ids = editor_ids | viewer_ids
+ implicit_folder_ids = (
+ FolderDAO.folders_with_accessible_assets(
+ user_id, folder_type or "analytics"
+ )
+ - member_ids
+ )
+ visible_ids = member_ids | implicit_folder_ids
+ folders = [f for f in folders if f.id in visible_ids]
+ else:
+ folders = []
+ # Hide other users' private folders (even for admins)
+ if user_id:
+ own_editor_ids = {
+ r[0]
+ for r in db.session.query(folder_editors.c.folder_id)
+ .filter(folder_editors.c.user_id == user_id)
+ .all()
+ }
+ folders = [
+ f for f in folders
+ if not f.is_private or f.id in own_editor_ids
+ ]
+
+ # Precompute counts to avoid N+1 queries
+ folder_ids = [f.id for f in folders]
+ children_counts: dict[int, int] = {}
+ asset_counts: dict[int, int] = {}
+ if folder_ids:
+ from sqlalchemy import func
+
+ for fid, cnt in (
+ db.session.query(Folder.parent_id, func.count())
+ .filter(Folder.parent_id.in_(folder_ids))
+ .group_by(Folder.parent_id)
+ .all()
+ ):
+ children_counts[fid] = cnt
+ from superset.folders.models import FolderObject
+
+ for fid, cnt in (
+ db.session.query(FolderObject.folder_id, func.count())
+ .filter(FolderObject.folder_id.in_(folder_ids))
+ .group_by(FolderObject.folder_id)
+ .all()
+ ):
+ asset_counts[fid] = cnt
+
+ result = [
+ serialize_folder(
+ folder,
+ children_count=children_counts.get(folder.id, 0),
+ asset_count=asset_counts.get(folder.id, 0),
+ implicit_folder_ids=implicit_folder_ids,
+ )
+ for folder in folders
+ ]
+ return self.response(200, count=len(result), result=result)
+
+ @expose("/assets", methods=("GET",))
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.root_assets",
+ log_to_statsd=False,
+ )
+ @parse_rison()
+ def root_assets(self, **kwargs: Any) -> Response:
+ """Root view: top-level folders plus unfoldered assets.
+ ---
+ get:
+ summary: Root folder view
+ parameters:
+ - in: query
+ name: folder_type
+ schema:
+ type: string
+ default: analytics
+ description: Folder type namespace to scope the view to.
+ - in: query
+ name: q
+ schema:
+ type: string
+ description: >
+ Rison-encoded query parameters (order_column, order_direction,
+ page, page_size, filters).
+ responses:
+ 200:
+ description: Top-level folders and assets not in any folder
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderRootResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ folder_type = request.args.get("folder_type", DEFAULT_FOLDER_TYPE)
+
+ # Lazy-create the "Only Me" folder for eligible users
+ user_id = get_user_id()
+ if user_id and can_manage_folders(g.user):
+ FolderDAO.get_or_create_only_me_folder(user_id)
+
+ parsed = _parse_rison_args(kwargs.get("rison", {}))
+ rows, count = FolderDAO.get_contents(None, folder_type, **parsed)
+ # Compute implicit folder IDs for user_permission serialization
+ implicit_ids: set[int] = set()
+ if not security_manager.is_admin():
+ user_id = get_user_id()
+ if user_id:
+ implicit_ids = FolderDAO.folders_with_accessible_assets(
+ user_id, folder_type
+ )
+ result = (
+ serialize_rows_with_paths(rows, None)
+ if parsed.get("search")
+ else [
+ serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+ for kind, obj in rows
+ ]
+ )
+ return self.response(
+ 200,
+ result=result,
+ count=count,
+ page=parsed["page"],
+ page_size=parsed["page_size"],
+ )
+
+ @expose("/<string:folder_uuid>", methods=("GET",))
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
+ log_to_statsd=False,
+ )
+ def get(self, folder_uuid: str) -> Response:
+ """Get a folder's details.
+ ---
+ get:
+ summary: Get a folder
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Folder details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ folder = FolderDAO.get_by_uuid(folder_uuid)
+ if not folder:
+ return self.response_404()
+ try:
+ self._raise_for_folder_access(folder)
+ except FolderForbiddenError:
+ return self.response_403()
+ return self.response(200, result=serialize_folder(folder))
+
+ @expose("/<string:folder_uuid>/assets", methods=("GET",))
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_assets",
+ log_to_statsd=False,
+ )
+ @parse_rison()
+ def get_assets(self, folder_uuid: str, **kwargs: Any) -> Response:
+ """List the subfolders and assets inside a folder.
+ ---
+ get:
+ summary: Get folder contents
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: q
+ schema:
+ type: string
+ description: >
+ Rison-encoded query parameters (order_column, order_direction,
+ page, page_size, filters).
+ responses:
+ 200:
+ description: Subfolders and assets inside the folder
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderContentsResponseSchema'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ folder = FolderDAO.get_by_uuid(folder_uuid)
+ if not folder:
+ return self.response_404()
+ try:
+ self._raise_for_folder_access(folder)
+ except FolderForbiddenError:
+ return self.response_403()
+ implicit_ids: set[int] = set()
+ user_id = get_user_id()
+ if user_id and not security_manager.is_admin():
+ implicit_ids = FolderDAO.folders_with_accessible_assets(
+ user_id, folder.folder_type
+ )
+ parsed = _parse_rison_args(kwargs.get("rison", {}))
+ rows, count = FolderDAO.get_contents(folder, folder.folder_type, **parsed)
+ result = (
+ serialize_rows_with_paths(rows, folder.id)
+ if parsed.get("search")
+ else [
+ serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+ for kind, obj in rows
+ ]
+ )
+ folder_implicit = set()
+ if (
+ user_id
+ and not security_manager.is_admin()
+ and not FolderPermissionDAO.user_has_folder_access(user_id, folder.id)
+ ):
+ folder_implicit = {folder.id}
+ return self.response(
+ 200,
+ folder=serialize_folder(folder, implicit_folder_ids=folder_implicit),
+ result=result,
+ count=count,
+ page=parsed["page"],
+ page_size=parsed["page_size"],
+ )
+
+ @expose("/", methods=("POST",))
+ @protect()
+ @safe
+ @permission_name("write")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
+ log_to_statsd=False,
+ )
+ @requires_json
+ def post(self) -> Response:
+ """Create a folder.
+ ---
+ post:
+ summary: Create a folder
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderPostSchema'
+ responses:
+ 201:
+ description: The folder was created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderResponseSchema'
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not can_manage_folders(g.user):
+ return self.response_403()
+ try:
+ data = self.add_model_schema.load(request.json)
+ except ValidationError as ex:
+ return self.response(400, message=ex.messages)
+ try:
+ folder = CreateFolderCommand(data).run()
+ return self.response(
+ 201,
+ id=folder.id,
+ uuid=str(folder.uuid),
+ result=serialize_folder(folder),
+ )
+ except FolderInvalidError as ex:
+ return self.response(422, message=ex.normalized_messages())
+ except FolderCreateFailedError as ex:
+ return self.response_422(message=str(ex))
+
+ @expose("/<string:folder_uuid>", methods=("PUT",))
+ @protect()
+ @safe
+ @permission_name("write")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
+ log_to_statsd=False,
+ )
+ @requires_json
+ def put(self, folder_uuid: str) -> Response:
+ """Update a folder (rename, describe, or move to a new parent).
+ ---
+ put:
+ summary: Update a folder
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderPutSchema'
+ responses:
+ 200:
+ description: The folder was updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderResponseSchema'
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not can_manage_folders(g.user):
+ return self.response_403()
+ try:
+ data = self.edit_model_schema.load(request.json)
+ except ValidationError as ex:
+ return self.response(400, message=ex.messages)
+ try:
+ folder = UpdateFolderCommand(folder_uuid, data).run()
+ return self.response(200, result=serialize_folder(folder))
+ except FolderNotFoundError:
+ return self.response_404()
+ except FolderForbiddenError:
+ return self.response_403()
+ except FolderInvalidError as ex:
+ return self.response(422, message=ex.normalized_messages())
+ except FolderUpdateFailedError as ex:
+ return self.response_422(message=str(ex))
+
+ @expose("/<string:folder_uuid>", methods=("DELETE",))
+ @protect()
+ @safe
+ @permission_name("write")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
+ log_to_statsd=False,
+ )
+ def delete(self, folder_uuid: str) -> Response:
+ """Delete a folder.
+
+ Children are re-parented to the deleted folder's parent and the assets it
+ contained become unfoldered.
+ ---
+ delete:
+ summary: Delete a folder
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: The folder was deleted
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not can_manage_folders(g.user):
+ return self.response_403()
+ archive_items = request.args.get("archive_items", "").lower() == "true"
+ try:
+ DeleteFolderCommand(folder_uuid, archive_items).run()
+ return self.response(200, message="OK")
+ except FolderNotFoundError:
+ return self.response_404()
+ except FolderForbiddenError:
+ return self.response_403()
+ except FolderDeleteFailedError as ex:
+ return self.response_422(message=str(ex))
+
+ @expose("/<string:folder_uuid>/assets", methods=("PUT",))
+ @protect()
+ @safe
+ @permission_name("write")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put_assets",
+ log_to_statsd=False,
+ )
+ @requires_json
+ def put_assets(self, folder_uuid: str) -> Response:
+ """Assign or move one or more assets into a folder.
+ ---
+ put:
+ summary: Assign assets to a folder
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderAssetsPutSchema'
+ responses:
+ 200:
+ description: The folder contents after assignment
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderContentsResponseSchema'
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not can_manage_folders(g.user):
+ return self.response_403()
+ try:
+ data = self.assets_model_schema.load(request.json)
+ except ValidationError as ex:
+ return self.response(400, message=ex.messages)
+ try:
+ folder = UpdateFolderAssetsCommand(folder_uuid, data["assets"]).run()
+ rows, count = FolderDAO.get_contents(folder, folder.folder_type)
+ return self.response(
+ 200,
+ folder=serialize_folder(folder),
+ result=[serialize_row(kind, obj) for kind, obj in rows],
+ count=count,
+ )
+ except FolderNotFoundError:
+ return self.response_404()
+ except FolderForbiddenError:
+ return self.response_403()
+ except FolderInvalidError as ex:
+ return self.response(422, message=ex.normalized_messages())
+ except FolderUpdateFailedError as ex:
+ return self.response_422(message=str(ex))
+
+ @expose("/<string:folder_uuid>/assets", methods=("POST",))
+ @protect()
+ @safe
+ @permission_name("write")
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post_assets",
+ log_to_statsd=False,
+ )
+ @requires_json
+ def post_assets(self, folder_uuid: str) -> Response:
+ """Add assets to a folder (upsert).
+
+ Assets already in another folder are moved. Existing assets in this
+ folder that are not listed are left untouched (unlike PUT which
+ replaces the full membership).
+ ---
+ post:
+ summary: Add assets to a folder
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FolderAssetsPutSchema'
+ responses:
+ 200:
+ description: Assets added
+ 400:
+ $ref: '#/components/responses/400'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
... 2716 lines suppressed ...