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/08/06 10:41:33 UTC
(superset) branch alexandrusoare/feat/folders-ux-poc created (now 3fe721e2864)
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a change to branch alexandrusoare/feat/folders-ux-poc
in repository https://gitbox.apache.org/repos/asf/superset.git
at 3fe721e2864 poc for ideas
This branch includes the following new commits:
new 3fe721e2864 poc for ideas
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: poc for ideas
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-ux-poc
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 3fe721e2864295e72d4d5ee9a388b668c89a6e0c
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Thu Aug 6 13:41:14 2026 +0300
poc for ideas
---
.../pages/AnalyticsList/FolderActivityModal.tsx | 244 +++++++++++++++++++++
.../src/pages/AnalyticsList/FolderInfoModal.tsx | 227 +++++++++++++++++++
.../src/pages/AnalyticsList/RecentBar.tsx | 145 ++++++++++++
.../src/pages/AnalyticsList/index.tsx | 46 +++-
superset/commands/folder/create.py | 7 +
superset/commands/folder/delete.py | 11 +
superset/commands/folder/update.py | 26 +++
superset/daos/folder.py | 16 ++
superset/folders/activity.py | 58 +++++
superset/folders/api.py | 109 ++++++++-
superset/folders/models.py | 26 +++
...12-00_a1b2c3d4e5f6_add_folder_activity_table.py | 63 ++++++
12 files changed, 974 insertions(+), 4 deletions(-)
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx
new file mode 100644
index 00000000000..79918fb77d4
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx
@@ -0,0 +1,244 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { StandardModal } from 'src/components/Modal';
+
+interface ActivityEvent {
+ id: number;
+ action: string;
+ target_type: string | null;
+ target_name: string | null;
+ details: Record<string, unknown> | null;
+ created_on: string | null;
+ user: { id: number; first_name: string; last_name: string } | null;
+}
+
+interface FolderActivityModalProps {
+ folderUuid: string;
+ folderName: string;
+ show: boolean;
+ onHide: () => void;
+}
+
+const Timeline = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit}px;
+ max-height: 400px;
+ overflow-y: auto;
+ `}
+`;
+
+const EventRow = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: flex-start;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 2}px;
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+
+ &:last-child {
+ border-bottom: none;
+ }
+ `}
+`;
+
+const EventDot = styled.div`
+ ${({ theme }) => `
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: ${theme.colorPrimary};
+ margin-top: 6px;
+ flex-shrink: 0;
+ `}
+`;
+
+const EventContent = styled.div`
+ flex: 1;
+ min-width: 0;
+`;
+
+const EventDescription = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ color: ${theme.colorText};
+ `}
+`;
+
+const EventMeta = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSizeXS}px;
+ color: ${theme.colorTextSecondary};
+ margin-top: 2px;
+ `}
+`;
+
+const EmptyState = styled.div`
+ ${({ theme }) => `
+ text-align: center;
+ padding: ${theme.sizeUnit * 6}px;
+ color: ${theme.colorTextSecondary};
+ `}
+`;
+
+const ACTION_LABELS: Record<string, string> = {
+ created: 'created this folder',
+ renamed: 'renamed this folder',
+ moved: 'moved this folder',
+ deleted: 'deleted a subfolder',
+ permission_changed: 'changed permissions',
+};
+
+function describeEvent(event: ActivityEvent): string {
+ const userName = event.user
+ ? `${event.user.first_name} ${event.user.last_name}`
+ : t('Unknown user');
+ const actionLabel = ACTION_LABELS[event.action] ?? event.action;
+
+ let suffix = '';
+ if (event.target_name && event.action === 'renamed') {
+ const oldName = event.details?.old_name;
+ suffix = oldName ? ` from "${oldName}" to "${event.target_name}"` : '';
+ } else if (event.target_name && event.action === 'deleted') {
+ suffix = ` "${event.target_name}"`;
+ } else if (event.action === 'permission_changed' && event.details) {
+ const detail = event.details;
+ if (detail.action === 'added') {
+ suffix = ` (added user as ${detail.permission})`;
+ } else if (detail.action === 'updated') {
+ suffix = ` (changed to ${detail.permission})`;
+ } else if (detail.action === 'removed') {
+ suffix = ' (removed user)';
+ } else if (detail.sync_permissions) {
+ suffix = ' (synced with parent)';
+ }
+ }
+
+ return `${userName} ${actionLabel}${suffix}`;
+}
+
+function formatTime(dateStr: string | null): string {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ if (diffMins < 1) return t('just now');
+ if (diffMins < 60) return t('%s min ago', diffMins);
+ const diffHours = Math.floor(diffMins / 60);
+ if (diffHours < 24) return t('%s hours ago', diffHours);
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays < 30) return t('%s days ago', diffDays);
+ return date.toLocaleDateString();
+}
+
+export default function FolderActivityModal({
+ folderUuid,
+ folderName,
+ show,
+ onHide,
+}: FolderActivityModalProps) {
+ const [events, setEvents] = useState<ActivityEvent[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(0);
+
+ const fetchActivity = useCallback(
+ (pageNum: number) => {
+ setLoading(true);
+ SupersetClient.get({
+ endpoint: `/api/v1/folders/${folderUuid}/activity?page=${pageNum}&page_size=25`,
+ }).then(
+ ({ json }) => {
+ const newEvents = (json?.result ?? []) as ActivityEvent[];
+ setEvents(prev =>
+ pageNum === 0 ? newEvents : [...prev, ...newEvents],
+ );
+ setTotal(json?.count ?? 0);
+ setLoading(false);
+ },
+ () => {
+ setLoading(false);
+ },
+ );
+ },
+ [folderUuid],
+ );
+
+ useEffect(() => {
+ if (show && folderUuid) {
+ setPage(0);
+ setEvents([]);
+ fetchActivity(0);
+ }
+ }, [show, folderUuid, fetchActivity]);
+
+ const handleLoadMore = () => {
+ const nextPage = page + 1;
+ setPage(nextPage);
+ fetchActivity(nextPage);
+ };
+
+ const hasMore = events.length < total;
+
+ return (
+ <StandardModal
+ title={`${folderName} — ${t('Activity')}`}
+ icon={<Icons.HistoryOutlined />}
+ show={show}
+ onHide={onHide}
+ onSave={onHide}
+ saveText={t('Close')}
+ contentLoading={loading && events.length === 0}
+ width={600}
+ >
+ {events.length === 0 && !loading ? (
+ <EmptyState>{t('No activity recorded for this folder')}</EmptyState>
+ ) : (
+ <Timeline>
+ {events.map(event => (
+ <EventRow key={event.id}>
+ <EventDot />
+ <EventContent>
+ <EventDescription>{describeEvent(event)}</EventDescription>
+ <EventMeta>{formatTime(event.created_on)}</EventMeta>
+ </EventContent>
+ </EventRow>
+ ))}
+ {hasMore && (
+ <EventRow
+ style={{ cursor: 'pointer', justifyContent: 'center' }}
+ onClick={handleLoadMore}
+ >
+ <EventDescription>
+ {loading ? t('Loading...') : t('Load more')}
+ </EventDescription>
+ </EventRow>
+ )}
+ </Timeline>
+ )}
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx
new file mode 100644
index 00000000000..e172a496d38
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx
@@ -0,0 +1,227 @@
+/**
+ * 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, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { StandardModal } from 'src/components/Modal';
+
+interface FolderDetails {
+ name: string;
+ description: string | null;
+ created_on: string;
+ changed_on: string;
+ created_by: { first_name: string; last_name: string } | null;
+ changed_by: { first_name: string; last_name: string } | null;
+ children_count: number;
+ asset_count: number;
+ asset_breakdown: {
+ dashboards: number;
+ charts: number;
+ };
+}
+
+interface FolderInfoModalProps {
+ folderUuid: string;
+ show: boolean;
+ onHide: () => void;
+}
+
+const InfoSection = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 2}px 0;
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+
+ &:last-child {
+ border-bottom: none;
+ }
+ `}
+`;
+
+const InfoLabel = styled.div`
+ ${({ theme }) => `
+ font-weight: ${theme.fontWeightStrong};
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorTextSecondary};
+ margin-bottom: ${theme.sizeUnit / 2}px;
+ `}
+`;
+
+const InfoValue = styled.div`
+ ${({ theme }) => `
+ font-size: ${theme.fontSize}px;
+ color: ${theme.colorText};
+ `}
+`;
+
+const SizeGrid = styled.div`
+ ${({ theme }) => `
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const SizeCard = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit}px;
+ padding: ${theme.sizeUnit * 2}px;
+ background: ${theme.colorBgLayout};
+ border-radius: ${theme.borderRadius}px;
+ `}
+`;
+
+const SizeNumber = styled.span`
+ ${({ theme }) => `
+ font-size: ${theme.fontSizeXL}px;
+ font-weight: ${theme.fontWeightStrong};
+ color: ${theme.colorText};
+ `}
+`;
+
+const SizeLabel = styled.span`
+ ${({ theme }) => `
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorTextSecondary};
+ `}
+`;
+
+const TotalRow = styled.div`
+ ${({ theme }) => `
+ margin-top: ${theme.sizeUnit * 2}px;
+ padding-top: ${theme.sizeUnit}px;
+ border-top: 1px solid ${theme.colorBorderSecondary};
+ font-weight: ${theme.fontWeightStrong};
+ color: ${theme.colorText};
+ `}
+`;
+
+function formatUser(
+ user: { first_name: string; last_name: string } | null,
+): string {
+ if (!user) return t('Unknown');
+ return `${user.first_name} ${user.last_name}`;
+}
+
+function formatDate(dateStr: string | null): string {
+ if (!dateStr) return '-';
+ return new Date(dateStr).toLocaleString();
+}
+
+export default function FolderInfoModal({
+ folderUuid,
+ show,
+ onHide,
+}: FolderInfoModalProps) {
+ const [details, setDetails] = useState<FolderDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!show || !folderUuid) return;
+ setLoading(true);
+ SupersetClient.get({
+ endpoint: `/api/v1/folders/${folderUuid}`,
+ }).then(
+ ({ json }) => {
+ setDetails(json?.result ?? null);
+ setLoading(false);
+ },
+ () => {
+ setLoading(false);
+ },
+ );
+ }, [show, folderUuid]);
+
+ const breakdown = details?.asset_breakdown;
+ const totalAssets =
+ (details?.asset_count ?? 0) + (details?.children_count ?? 0);
+
+ return (
+ <StandardModal
+ title={details?.name ?? t('Folder Information')}
+ icon={<Icons.FolderOutlined />}
+ show={show}
+ onHide={onHide}
+ onSave={onHide}
+ saveText={t('Close')}
+ contentLoading={loading}
+ width={500}
+ >
+ {details && (
+ <>
+ {details.description && (
+ <InfoSection>
+ <InfoLabel>{t('Description')}</InfoLabel>
+ <InfoValue>{details.description}</InfoValue>
+ </InfoSection>
+ )}
+
+ <InfoSection>
+ <InfoLabel>{t('Created by')}</InfoLabel>
+ <InfoValue>
+ {formatUser(details.created_by)} —{' '}
+ {formatDate(details.created_on)}
+ </InfoValue>
+ </InfoSection>
+
+ <InfoSection>
+ <InfoLabel>{t('Last modified by')}</InfoLabel>
+ <InfoValue>
+ {formatUser(details.changed_by)} —{' '}
+ {formatDate(details.changed_on)}
+ </InfoValue>
+ </InfoSection>
+
+ <InfoSection>
+ <InfoLabel>{t('Folder size')}</InfoLabel>
+ <SizeGrid>
+ <SizeCard>
+ <Icons.LayoutOutlined iconSize="l" />
+ <div>
+ <SizeNumber>{breakdown?.dashboards ?? 0}</SizeNumber>{' '}
+ <SizeLabel>{t('Dashboards')}</SizeLabel>
+ </div>
+ </SizeCard>
+ <SizeCard>
+ <Icons.LineChartOutlined iconSize="l" />
+ <div>
+ <SizeNumber>{breakdown?.charts ?? 0}</SizeNumber>{' '}
+ <SizeLabel>{t('Charts')}</SizeLabel>
+ </div>
+ </SizeCard>
+ <SizeCard>
+ <Icons.FolderOutlined iconSize="l" />
+ <div>
+ <SizeNumber>{details.children_count}</SizeNumber>{' '}
+ <SizeLabel>{t('Subfolders')}</SizeLabel>
+ </div>
+ </SizeCard>
+ </SizeGrid>
+ <TotalRow>
+ {t('Total')}: {totalAssets} {t('assets')}
+ </TotalRow>
+ </InfoSection>
+ </>
+ )}
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx b/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx
new file mode 100644
index 00000000000..c257273ef58
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { Tooltip } from '@superset-ui/core/components';
+import rison from 'rison';
+
+interface RecentItem {
+ action: string;
+ item_type: 'dashboard' | 'slice' | null;
+ item_url: string | null;
+ item_title: string | null;
+ time_delta_humanized: string;
+}
+
+const RecentBarWrapper = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
+ overflow-x: auto;
+ white-space: nowrap;
+
+ &::-webkit-scrollbar {
+ height: 4px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: ${theme.colorBorderSecondary};
+ border-radius: 2px;
+ }
+ `}
+`;
+
+const SectionLabel = styled.span`
+ ${({ theme }) => `
+ font-weight: ${theme.fontWeightStrong};
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorTextSecondary};
+ flex-shrink: 0;
+ `}
+`;
+
+const RecentCard = styled.a`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ gap: ${theme.sizeUnit}px;
+ padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+ border: 1px solid ${theme.colorBorderSecondary};
+ border-radius: ${theme.borderRadius}px;
+ background: ${theme.colorBgContainer};
+ color: ${theme.colorText};
+ text-decoration: none;
+ flex-shrink: 0;
+ max-width: 200px;
+ cursor: pointer;
+ transition: border-color 0.2s;
+
+ &:hover {
+ border-color: ${theme.colorPrimary};
+ color: ${theme.colorPrimary};
+ }
+ `}
+`;
+
+const CardTitle = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const TimeAgo = styled.span`
+ ${({ theme }) => `
+ font-size: ${theme.fontSizeXS}px;
+ color: ${theme.colorTextSecondary};
+ flex-shrink: 0;
+ `}
+`;
+
+export default function RecentBar() {
+ const [items, setItems] = useState<RecentItem[]>([]);
+
+ useEffect(() => {
+ const q = rison.encode({
+ page: 0,
+ page_size: 10,
+ distinct: true,
+ });
+ SupersetClient.get({
+ endpoint: `/api/v1/log/recent_activity/?q=${q}`,
+ }).then(
+ ({ json }) => {
+ const results = (json?.result ?? []) as RecentItem[];
+ setItems(results.filter(r => r.item_url && r.item_title).slice(0, 8));
+ },
+ () => {},
+ );
+ }, []);
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return (
+ <RecentBarWrapper>
+ <SectionLabel>{t('Recent')}</SectionLabel>
+ {items.map((item, idx) => (
+ <Tooltip
+ key={`${item.item_url}-${idx}`}
+ title={`${item.item_title} — ${item.time_delta_humanized}`}
+ >
+ <RecentCard href={item.item_url ?? '#'}>
+ {item.item_type === 'dashboard' ? (
+ <Icons.LayoutOutlined iconSize="s" />
+ ) : (
+ <Icons.LineChartOutlined iconSize="s" />
+ )}
+ <CardTitle>{item.item_title}</CardTitle>
+ <TimeAgo>{item.time_delta_humanized}</TimeAgo>
+ </RecentCard>
+ </Tooltip>
+ ))}
+ </RecentBarWrapper>
+ );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index 155b50001a3..1c28127ebe9 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -77,6 +77,9 @@ import FolderPermissionsModal from './FolderPermissionsModal';
import RenameFolderModal from './RenameFolderModal';
import DeleteFolderModal from './DeleteFolderModal';
import DashboardCharts from './DashboardCharts';
+import RecentBar from './RecentBar';
+import FolderInfoModal from './FolderInfoModal';
+import FolderActivityModal from './FolderActivityModal';
import type { ContentItem } from './types';
/** A location in the drill path; the root has a null uuid. */
@@ -213,6 +216,10 @@ function AnalyticsList({
Record<number, { loading: boolean; charts: ContentItem[] }>
>({});
const [showCreateFolder, setShowCreateFolder] = useState(false);
+ const [folderToShowInfo, setFolderToShowInfo] =
+ useState<ContentItem | null>(null);
+ const [folderToShowActivity, setFolderToShowActivity] =
+ useState<ContentItem | null>(null);
const [folderToManagePerms, setFolderToManagePerms] =
useState<ContentItem | null>(null);
const [folderToRename, setFolderToRename] = useState<ContentItem | null>(
@@ -936,6 +943,26 @@ function AnalyticsList({
const canEdit = original.user_permission === 'editor';
return (
<Actions className="actions">
+ <Tooltip title={t('Folder info')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setFolderToShowInfo(original)}
+ >
+ <Icons.InfoCircleOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ <Tooltip title={t('Activity')} placement="bottom">
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setFolderToShowActivity(original)}
+ >
+ <Icons.HistoryOutlined iconSize="l" />
+ </span>
+ </Tooltip>
{canEdit && (
<Tooltip title={t('Manage permissions')} placement="bottom">
<span
@@ -1286,6 +1313,21 @@ function AnalyticsList({
return (
<>
{expandedRowStyle}
+ {folderToShowInfo && (
+ <FolderInfoModal
+ folderUuid={folderToShowInfo.uuid ?? ''}
+ show
+ onHide={() => setFolderToShowInfo(null)}
+ />
+ )}
+ {folderToShowActivity && (
+ <FolderActivityModal
+ folderUuid={folderToShowActivity.uuid ?? ''}
+ folderName={folderToShowActivity.name}
+ show
+ onHide={() => setFolderToShowActivity(null)}
+ />
+ )}
{showCreateFolder && (
<CreateFolderModal
show
@@ -1406,7 +1448,9 @@ function AnalyticsList({
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={() => setBulkSelectEnabled(false)}
headerContent={
- isAtRoot ? null : (
+ isAtRoot ? (
+ <RecentBar />
+ ) : (
<BreadcrumbWrap>
<FolderBreadcrumb items={breadcrumbItems} />
</BreadcrumbWrap>
diff --git a/superset/commands/folder/create.py b/superset/commands/folder/create.py
index d54661dd436..fd92736054b 100644
--- a/superset/commands/folder/create.py
+++ b/superset/commands/folder/create.py
@@ -39,6 +39,7 @@ 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
+from superset.folders.activity import log_folder_activity
class CreateFolderCommand(BaseCommand):
@@ -69,6 +70,12 @@ class CreateFolderCommand(BaseCommand):
FolderPermissionDAO.copy_permissions_to_subfolder(
self._parent.id, folder.id
)
+ log_folder_activity(
+ folder.id,
+ "created",
+ target_type="folder",
+ target_name=folder.name,
+ )
return folder
def validate(self) -> None:
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
index 014da68235e..132751e73c8 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/delete.py
@@ -31,6 +31,7 @@ from superset.commands.folder.exceptions import (
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.folders.activity import log_folder_activity
class DeleteFolderCommand(BaseCommand):
@@ -43,6 +44,16 @@ class DeleteFolderCommand(BaseCommand):
def run(self) -> None:
self.validate()
assert self._model
+ # Log before delete since CASCADE will remove the activity record's
+ # parent folder. We log to the parent folder if one exists so the
+ # activity is preserved; otherwise we accept it will be lost.
+ if self._model.parent_id:
+ log_folder_activity(
+ self._model.parent_id,
+ "deleted",
+ target_type="folder",
+ target_name=self._model.name,
+ )
FolderDAO.delete_folder(self._model, self._archive_items)
def validate(self) -> None:
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
index 0c8951d7506..f89f6302bfa 100644
--- a/superset/commands/folder/update.py
+++ b/superset/commands/folder/update.py
@@ -38,6 +38,7 @@ from superset.commands.folder.exceptions import (
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.folders.activity import log_folder_activity
class UpdateFolderCommand(BaseCommand):
@@ -52,6 +53,7 @@ class UpdateFolderCommand(BaseCommand):
def run(self) -> Folder:
self.validate()
assert self._model
+ old_name = self._model.name
attributes: dict[str, Any] = {}
if "name" in self._properties:
@@ -82,6 +84,30 @@ class UpdateFolderCommand(BaseCommand):
folder.parent_id, folder.id
)
FolderPermissionDAO.push_down_permissions(folder.id)
+ log_folder_activity(
+ folder.id,
+ "permission_changed",
+ target_type="folder",
+ target_name=folder.name,
+ details={"sync_permissions": True},
+ )
+
+ if "name" in self._properties and self._properties["name"] != old_name:
+ log_folder_activity(
+ folder.id,
+ "renamed",
+ target_type="folder",
+ target_name=folder.name,
+ details={"old_name": old_name},
+ )
+
+ if self._parent_changed:
+ log_folder_activity(
+ folder.id,
+ "moved",
+ target_type="folder",
+ target_name=folder.name,
+ )
return folder
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index a1af64ddee3..35d6977cc85 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -98,6 +98,22 @@ class FolderDAO(BaseDAO[Folder]):
ids_by_type[name].append(asset_id)
return cls._load_assets(ids_by_type)
+ @classmethod
+ def get_asset_breakdown(cls, folder_id: int) -> dict[str, int]:
+ """Count assets in a folder grouped by type (which FK column is set)."""
+ result = (
+ db.session.query(
+ func.count(FolderObject.dashboard_id).label("dashboards"),
+ func.count(FolderObject.chart_id).label("charts"),
+ )
+ .filter(FolderObject.folder_id == folder_id)
+ .one()
+ )
+ return {
+ "dashboards": result.dashboards,
+ "charts": result.charts,
+ }
+
@classmethod
def get_contents( # noqa: C901
cls,
diff --git a/superset/folders/activity.py b/superset/folders/activity.py
new file mode 100644
index 00000000000..d0b32e1b301
--- /dev/null
+++ b/superset/folders/activity.py
@@ -0,0 +1,58 @@
+# 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.
+"""Helper for logging folder activity events."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from superset.extensions import db
+from superset.folders.models import FolderActivity
+from superset.utils.core import get_user_id
+
+logger = logging.getLogger(__name__)
+
+
+def log_folder_activity(
+ folder_id: int,
+ action: str,
+ target_type: str | None = None,
+ target_name: str | None = None,
+ details: dict[str, Any] | None = None,
+) -> None:
+ """Record a folder activity event.
+
+ This should be called within an existing transaction (e.g. inside a
+ Command's ``run()`` method) so the activity record commits or rolls
+ back with the rest of the operation.
+ """
+ user_id = get_user_id()
+ if not user_id:
+ logger.warning("Cannot log folder activity: no authenticated user")
+ return
+
+ activity = FolderActivity(
+ folder_id=folder_id,
+ user_id=user_id,
+ action=action,
+ target_type=target_type,
+ target_name=target_name,
+ details=json.dumps(details) if details else None,
+ )
+ db.session.add(activity)
diff --git a/superset/folders/api.py b/superset/folders/api.py
index 1f0662e3874..327776294f6 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+import json
import logging
from datetime import datetime
from typing import Any
@@ -50,10 +51,11 @@ from superset.commands.folder.exceptions import (
)
from superset.commands.folder.update import UpdateFolderCommand
from superset.daos.folder import FolderDAO, ResolvedAsset
+from superset.folders.activity import log_folder_activity
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
+from superset.folders.models import Folder, FolderActivity
from superset.folders.schemas import (
FolderAssetSchema,
FolderAssetsPutSchema,
@@ -114,14 +116,18 @@ def serialize_folder(
folder: Folder,
children_count: int | None = None,
asset_count: int | None = None,
+ include_breakdown: bool = False,
) -> 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).
+
+ When ``include_breakdown`` is True, an ``asset_breakdown`` dict with
+ per-type counts (dashboards, charts) is included in the response.
"""
- return {
+ data: dict[str, Any] = {
"type": "folder",
"id": folder.id,
"uuid": str(folder.uuid),
@@ -147,6 +153,9 @@ def serialize_folder(
_serialize_user(u) for u in (folder.editors or [])
],
}
+ if include_breakdown:
+ data["asset_breakdown"] = FolderDAO.get_asset_breakdown(folder.id)
+ return data
def serialize_row(
@@ -556,7 +565,10 @@ class FolderRestApi(BaseSupersetApi):
self._raise_for_folder_access(folder)
except FolderForbiddenError:
return self.response_403()
- return self.response(200, result=serialize_folder(folder))
+ return self.response(
+ 200,
+ result=serialize_folder(folder, include_breakdown=True),
+ )
@expose("/<string:folder_uuid>/assets", methods=("GET",))
@protect()
@@ -1211,6 +1223,13 @@ class FolderRestApi(BaseSupersetApi):
return self.response(422, message=str(ex))
FolderPermissionDAO.mark_permissions_explicit(folder.id)
FolderPermissionDAO.push_down_permissions(folder.id)
+ log_folder_activity(
+ folder.id,
+ "permission_changed",
+ target_type="user",
+ target_name=str(data["user_id"]),
+ details={"action": "added", "permission": permission},
+ )
return self.response(201, message="OK")
@expose("/<folder_uuid>/subjects/<int:user_id>", methods=("PUT",))
@@ -1265,6 +1284,13 @@ class FolderRestApi(BaseSupersetApi):
FolderDAO.update_subject(folder.id, user_id, permission)
FolderPermissionDAO.mark_permissions_explicit(folder.id)
FolderPermissionDAO.push_down_permissions(folder.id)
+ log_folder_activity(
+ folder.id,
+ "permission_changed",
+ target_type="user",
+ target_name=str(user_id),
+ details={"action": "updated", "permission": permission},
+ )
return self.response(200, message="OK")
@expose("/<folder_uuid>/subjects/<int:user_id>", methods=("DELETE",))
@@ -1305,6 +1331,13 @@ class FolderRestApi(BaseSupersetApi):
FolderDAO.remove_subject(folder.id, user_id)
FolderPermissionDAO.mark_permissions_explicit(folder.id)
FolderPermissionDAO.push_down_permissions(folder.id)
+ log_folder_activity(
+ folder.id,
+ "permission_changed",
+ target_type="user",
+ target_name=str(user_id),
+ details={"action": "removed"},
+ )
return self.response(200, message="OK")
@expose("/<folder_uuid>/available-users", methods=("GET",))
@@ -1392,3 +1425,73 @@ class FolderRestApi(BaseSupersetApi):
],
count=total,
)
+
+ @expose("/<folder_uuid>/activity", methods=("GET",))
+ @protect()
+ @safe
+ @permission_name("read")
+ @statsd_metrics
+ def get_activity(self, folder_uuid: str) -> Response:
+ """Get activity log for a folder.
+ ---
+ get:
+ summary: Get folder activity log
+ parameters:
+ - in: path
+ name: folder_uuid
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: page
+ schema:
+ type: integer
+ default: 0
+ - in: query
+ name: page_size
+ schema:
+ type: integer
+ default: 25
+ responses:
+ 200:
+ description: Paginated activity log
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ 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()
+
+ page = _positive_int("page", 0)
+ page_size = _positive_int("page_size", 25)
+
+ query = (
+ db.session.query(FolderActivity, User)
+ .outerjoin(User, User.id == FolderActivity.user_id)
+ .filter(FolderActivity.folder_id == folder.id)
+ .order_by(FolderActivity.created_on.desc())
+ )
+ total = query.count()
+ rows = query.offset(page * page_size).limit(page_size).all()
+
+ result = []
+ for activity, user in rows:
+ result.append({
+ "id": activity.id,
+ "action": activity.action,
+ "target_type": activity.target_type,
+ "target_name": activity.target_name,
+ "details": json.loads(activity.details) if activity.details else None,
+ "created_on": activity.created_on.isoformat() if activity.created_on else None,
+ "user": {
+ "id": user.id,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ } if user else None,
+ })
+
+ return self.response(200, result=result, count=total)
diff --git a/superset/folders/models.py b/superset/folders/models.py
index 4fa1d2c0d58..eae7436db4b 100644
--- a/superset/folders/models.py
+++ b/superset/folders/models.py
@@ -44,6 +44,7 @@ from sqlalchemy import (
Table,
Text,
UniqueConstraint,
+ func,
)
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
@@ -257,3 +258,28 @@ class FolderPin(Model):
if self.dashboard_id is not None:
return self.dashboard_id
return self.chart_id
+
+
+class FolderActivity(Model):
+ """Audit log for folder-related events (create, rename, move, etc.)."""
+
+ __tablename__ = "folder_activity"
+ __table_args__ = (
+ Index("ix_folder_activity_folder_id", "folder_id"),
+ Index("ix_folder_activity_created_on", "created_on"),
+ )
+
+ id = Column(Integer, primary_key=True)
+ folder_id = Column(
+ Integer,
+ ForeignKey("folders.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ user_id = Column(Integer, nullable=False)
+ action = Column(String(50), nullable=False)
+ target_type = Column(String(50), nullable=True)
+ target_name = Column(String(250), nullable=True)
+ details = Column(Text, nullable=True)
+ created_on = Column(DateTime, nullable=False, server_default=func.now())
+
+ folder = relationship("Folder")
diff --git a/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py b/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py
new file mode 100644
index 00000000000..f64056c7133
--- /dev/null
+++ b/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Add folder_activity table
+
+Revision ID: a1b2c3d4e5f6
+Revises: 4567cf3d03cc
+Create Date: 2026-08-04 12:00:00.000000
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "a1b2c3d4e5f6"
+down_revision = "4567cf3d03cc"
+
+
+def upgrade():
+ op.create_table(
+ "folder_activity",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column(
+ "folder_id",
+ sa.Integer(),
+ sa.ForeignKey("folders.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("action", sa.String(50), nullable=False),
+ sa.Column("target_type", sa.String(50), nullable=True),
+ sa.Column("target_name", sa.String(250), nullable=True),
+ sa.Column("details", sa.Text(), nullable=True),
+ sa.Column(
+ "created_on",
+ sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ )
+ op.create_index(
+ "ix_folder_activity_folder_id", "folder_activity", ["folder_id"]
+ )
+ op.create_index(
+ "ix_folder_activity_created_on", "folder_activity", ["created_on"]
+ )
+
+
+def downgrade():
+ op.drop_table("folder_activity")