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:34 UTC
(superset) 01/01: poc for ideas
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")