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:20 UTC
(superset) branch alexandrusoare/feat/folders-testing-phase2-v2 created (now b892bba091f)
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a change to branch alexandrusoare/feat/folders-testing-phase2-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
at b892bba091f latest state
This branch includes the following new commits:
new b892bba091f latest state
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: latest state
Posted by as...@apache.org.
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 ...