chore: refactor database view tags

This commit is contained in:
Nathan
2025-11-20 15:08:18 +08:00
parent 0b00809f12
commit d69c6f5c3f
3 changed files with 306 additions and 195 deletions

View File

@@ -0,0 +1,166 @@
import {
DatabaseViewLayout,
View,
ViewLayout,
YDatabaseView,
YjsDatabaseKey,
} from '@/application/types';
import PageIcon from '@/components/_shared/view-icon/PageIcon';
import { DatabaseViewActions } from '@/components/database/components/tabs/ViewActions';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TabLabel, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export interface DatabaseTabItemProps {
viewId: string;
view: YDatabaseView;
meta: View | null;
iidIndex: string;
menuViewId: string | null;
readOnly: boolean;
visibleViewIds: string[];
onSetMenuViewId: (id: string | null) => void;
onOpenDeleteModal: (id: string) => void;
onOpenRenameModal: (id: string) => void;
onReloadView: () => void;
setTabRef: (id: string, el: HTMLElement | null) => void;
}
export const DatabaseTabItem = memo(
({
viewId,
view,
meta,
iidIndex,
menuViewId,
readOnly,
visibleViewIds,
onSetMenuViewId,
onOpenDeleteModal,
onOpenRenameModal,
onReloadView,
setTabRef,
}: DatabaseTabItemProps) => {
const { t } = useTranslation();
const databaseLayout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const folderView =
viewId === iidIndex ? meta : meta?.children?.find((v) => v.view_id === viewId);
const name = folderView?.name || view.get(YjsDatabaseKey.name) || t('untitled');
const menuView = useMemo(() => {
if (menuViewId === iidIndex) return meta;
return meta?.children.find((v) => v.view_id === menuViewId);
}, [iidIndex, menuViewId, meta]);
return (
<TabsTrigger
key={viewId}
value={viewId}
id={`view-tab-${viewId}`}
data-testid={`view-tab-${viewId}`}
className={'min-w-[80px] max-w-[200px]'}
ref={(el) => {
setTabRef(viewId, el);
}}
>
<TabLabel
onPointerDown={(e) => {
// For left-click, let Radix UI tabs handle it via onValueChange
if (e.button === 0) {
return;
}
// For right-click and other buttons, prevent default and handle menu
e.preventDefault();
e.stopPropagation();
if (readOnly) return;
if (viewId !== menuViewId) {
onSetMenuViewId(viewId);
} else {
onSetMenuViewId(null);
}
}}
className={'flex items-center gap-1.5 overflow-hidden'}
>
<PageIcon
iconSize={16}
view={
folderView || {
layout:
databaseLayout === DatabaseViewLayout.Board
? ViewLayout.Board
: databaseLayout === DatabaseViewLayout.Calendar
? ViewLayout.Calendar
: ViewLayout.Grid,
}
}
className={'!h-5 !w-5 text-base leading-[1.3rem]'}
/>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<span
onContextMenu={(e) => {
e.preventDefault();
}}
className={'flex-1 truncate'}
>
{name || t('grid.title.placeholder')}
</span>
</TooltipTrigger>
<TooltipContent sideOffset={10} side={'right'}>
{name}
</TooltipContent>
</Tooltip>
</TabLabel>
<DropdownMenu
modal
onOpenChange={(open) => {
if (!open) {
onSetMenuViewId(null);
}
}}
open={menuViewId === viewId}
>
<DropdownMenuTrigger asChild>
<div className={'pointer-events-none absolute bottom-0 left-0 opacity-0'} />
</DropdownMenuTrigger>
<DropdownMenuContent
side={'bottom'}
align={'start'}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{menuView && (
<DatabaseViewActions
onClose={() => {
onSetMenuViewId(null);
}}
onOpenDeleteModal={(viewId: string) => {
onOpenDeleteModal(viewId);
}}
onOpenRenameModal={(viewId: string) => {
onOpenRenameModal(viewId);
}}
deleteDisabled={viewId === iidIndex && visibleViewIds.length > 1}
view={menuView}
onUpdatedIcon={onReloadView}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</TabsTrigger>
);
}
);
DatabaseTabItem.displayName = 'DatabaseTabItem';

View File

@@ -12,7 +12,6 @@ import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg';
import { findView } from '@/components/_shared/outline/utils'; import { findView } from '@/components/_shared/outline/utils';
import { AFScroller } from '@/components/_shared/scroller'; import { AFScroller } from '@/components/_shared/scroller';
import { ViewIcon } from '@/components/_shared/view-icon'; import { ViewIcon } from '@/components/_shared/view-icon';
import PageIcon from '@/components/_shared/view-icon/PageIcon';
import { import {
SCROLL_DELAY, SCROLL_DELAY,
SCROLL_FALLBACK_DELAY, SCROLL_FALLBACK_DELAY,
@@ -20,13 +19,13 @@ import {
import { useDatabaseViewSync } from '@/components/app/hooks/useViewSync'; import { useDatabaseViewSync } from '@/components/app/hooks/useViewSync';
import RenameModal from '@/components/app/view-actions/RenameModal'; import RenameModal from '@/components/app/view-actions/RenameModal';
import { DatabaseActions } from '@/components/database/components/conditions'; import { DatabaseActions } from '@/components/database/components/conditions';
import { DatabaseTabItem } from '@/components/database/components/tabs/DatabaseTabItem';
import DeleteViewConfirm from '@/components/database/components/tabs/DeleteViewConfirm'; import DeleteViewConfirm from '@/components/database/components/tabs/DeleteViewConfirm';
import { DatabaseViewActions } from '@/components/database/components/tabs/ViewActions'; import { useTabScroller } from '@/components/database/components/tabs/useTabScroller';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { TabLabel, Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface DatabaseTabBarProps { export interface DatabaseTabBarProps {
viewIds: string[]; viewIds: string[];
@@ -46,7 +45,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
const { loadViewMeta, readOnly, showActions = true, eventEmitter } = context; const { loadViewMeta, readOnly, showActions = true, eventEmitter } = context;
const updatePage = useUpdateDatabaseView(); const updatePage = useUpdateDatabaseView();
const [meta, setMeta] = useState<View | null>(null); const [meta, setMeta] = useState<View | null>(null);
const scrollLeft = context.paddingStart; const scrollLeftPadding = context.paddingStart;
const [addLoading, setAddLoading] = useState(false); const [addLoading, setAddLoading] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>();
const [renameViewId, setRenameViewId] = useState<string | null>(); const [renameViewId, setRenameViewId] = useState<string | null>();
@@ -54,29 +53,32 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
const [tabsWidth, setTabsWidth] = useState<number | null>(null); const [tabsWidth, setTabsWidth] = useState<number | null>(null);
const [tabsContainer, setTabsContainer] = useState<HTMLDivElement | null>(null); const [tabsContainer, setTabsContainer] = useState<HTMLDivElement | null>(null);
const [showScrollRightButton, setShowScrollRightButton] = useState(false);
const [showScrollLeftButton, setShowScrollLeftButton] = useState(false);
const [scrollerContainer, setScrollerContainer] = useState<HTMLDivElement | null>(null);
const tabRefs = useRef<Map<string, HTMLElement>>(new Map()); const tabRefs = useRef<Map<string, HTMLElement>>(new Map());
const { waitForViewData } = useDatabaseViewSync(views as any); const {
setScrollerContainer,
showScrollLeftButton,
showScrollRightButton,
scrollLeft,
scrollRight,
handleObserverScroller,
} = useTabScroller();
const scrollToView = useCallback( const { waitForViewData } = useDatabaseViewSync(views);
(viewId: string) => {
const element = tabRefs.current.get(viewId);
if (element) { const scrollToView = useCallback((viewId: string) => {
element.scrollIntoView({ const element = tabRefs.current.get(viewId);
behavior: 'smooth',
block: 'nearest', if (element) {
inline: 'center', element.scrollIntoView({
}); behavior: 'smooth',
return true; block: 'nearest',
} inline: 'center',
return false; });
}, return true;
[] }
); return false;
}, []);
const navigateToView = useCallback( const navigateToView = useCallback(
async (viewId: string) => { async (viewId: string) => {
@@ -97,18 +99,6 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
[setSelectedViewId, scrollToView] [setSelectedViewId, scrollToView]
); );
const handleObserverScroller = useCallback(() => {
if (scrollerContainer) {
const scrollWidth = scrollerContainer.scrollWidth;
const clientWidth = scrollerContainer.clientWidth;
setShowScrollRightButton(
scrollWidth > clientWidth && scrollerContainer.scrollLeft + 1 < scrollWidth - clientWidth
);
setShowScrollLeftButton(scrollerContainer.scrollLeft > 5);
}
}, [scrollerContainer]);
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
if (tabsContainer) { if (tabsContainer) {
@@ -134,30 +124,13 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
}; };
}, [tabsContainer]); }, [tabsContainer]);
useEffect(() => {
if (!scrollerContainer) return;
const onResize = () => {
handleObserverScroller();
};
// Initial call to set the width
onResize();
const observer = new ResizeObserver(onResize);
observer.observe(scrollerContainer);
return () => {
observer.disconnect();
};
}, [handleObserverScroller, scrollerContainer]);
const reloadView = useCallback(async () => { const reloadView = useCallback(async () => {
if (loadViewMeta) { if (loadViewMeta) {
try { try {
const meta = await loadViewMeta(iidIndex); const meta = await loadViewMeta(iidIndex);
setMeta(meta); setMeta(meta);
return meta;
} catch (e) { } catch (e) {
// do nothing // do nothing
} }
@@ -189,11 +162,6 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return meta?.children.find((v) => v.view_id === renameViewId); return meta?.children.find((v) => v.view_id === renameViewId);
}, [iidIndex, meta, renameViewId]); }, [iidIndex, meta, renameViewId]);
const menuView = useMemo(() => {
if (menuViewId === iidIndex) return meta;
return meta?.children.find((v) => v.view_id === menuViewId);
}, [iidIndex, menuViewId, meta]);
const visibleViewIds = useMemo(() => { const visibleViewIds = useMemo(() => {
return viewIds.filter((viewId) => { return viewIds.filter((viewId) => {
const databaseView = views?.get(viewId) as YDatabaseView | null; const databaseView = views?.get(viewId) as YDatabaseView | null;
@@ -212,7 +180,14 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
const synced = await waitForViewData(viewId); const synced = await waitForViewData(viewId);
// Reload view metadata to ensure folder structure is updated // Reload view metadata to ensure folder structure is updated
await reloadView(); const meta = await reloadView();
// Sometimes the view metadata is not immediately available after creation due to race conditions on the backend/sync
// If the new view is not found in the children list, wait a bit and try reloading again
if (meta && !meta.children.some((v) => v.view_id === viewId)) {
await new Promise((resolve) => setTimeout(resolve, 500));
await reloadView();
}
if (synced) { if (synced) {
await navigateToView(viewId); await navigateToView(viewId);
@@ -258,17 +233,28 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
document.removeEventListener('contextmenu', preventDefault); document.removeEventListener('contextmenu', preventDefault);
}; };
}, [menuViewId]); }, [menuViewId]);
const setTabRef = useCallback((viewId: string, el: HTMLElement | null) => {
if (el) {
tabRefs.current.set(viewId, el);
} else {
tabRefs.current.delete(viewId);
}
}, []);
if (viewIds.length === 0) return null; if (viewIds.length === 0) return null;
return ( return (
<div <div
ref={ref} ref={ref}
className={className} className={className}
style={{ style={{
paddingLeft: scrollLeft === undefined ? 96 : scrollLeft, paddingLeft: scrollLeftPadding === undefined ? 96 : scrollLeftPadding,
paddingRight: scrollLeft === undefined ? 96 : scrollLeft, paddingRight: scrollLeftPadding === undefined ? 96 : scrollLeftPadding,
}} }}
> >
<div className={`database-tabs flex w-full items-center gap-1.5 overflow-hidden border-b border-border-primary`}> <div
className={`database-tabs flex w-full items-center gap-1.5 overflow-hidden border-b border-border-primary`}
>
<div className='relative flex h-[34px] flex-1 items-end justify-start overflow-hidden'> <div className='relative flex h-[34px] flex-1 items-end justify-start overflow-hidden'>
{showScrollLeftButton && ( {showScrollLeftButton && (
<Button <Button
@@ -280,15 +266,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
'absolute left-0 top-0 z-10 bg-surface-primary text-icon-secondary hover:bg-surface-primary-hover ' 'absolute left-0 top-0 z-10 bg-surface-primary text-icon-secondary hover:bg-surface-primary-hover '
} }
variant={'ghost'} variant={'ghost'}
tabIndex={-1} onClick={scrollLeft}
onClick={() => {
if (scrollerContainer) {
scrollerContainer.scrollTo({
left: scrollerContainer.scrollLeft - 200,
behavior: 'smooth',
});
}
}}
> >
<ChevronLeft className={'h-5 w-5'} /> <ChevronLeft className={'h-5 w-5'} />
</Button> </Button>
@@ -304,15 +282,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
'absolute right-9 top-0 z-10 bg-surface-primary text-icon-secondary hover:bg-surface-primary-hover' 'absolute right-9 top-0 z-10 bg-surface-primary text-icon-secondary hover:bg-surface-primary-hover'
} }
variant={'ghost'} variant={'ghost'}
tabIndex={-1} onClick={scrollRight}
onClick={() => {
if (scrollerContainer) {
scrollerContainer.scrollTo({
left: scrollerContainer.scrollLeft + 200,
behavior: 'smooth',
});
}
}}
> >
<ChevronRight className={'h-5 w-5'} /> <ChevronRight className={'h-5 w-5'} />
</Button> </Button>
@@ -325,13 +295,8 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
}} }}
className={'relative flex h-full flex-1'} className={'relative flex h-full flex-1'}
overflowYHidden overflowYHidden
ref={(el: HTMLDivElement | null) => { ref={setScrollerContainer}
setScrollerContainer(el); onScroll={handleObserverScroller}
handleObserverScroller();
}}
onScroll={() => {
handleObserverScroller();
}}
> >
<div <div
ref={setTabsContainer} ref={setTabsContainer}
@@ -354,114 +319,23 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
const view = views?.get(viewId) as YDatabaseView | null; const view = views?.get(viewId) as YDatabaseView | null;
if (!view) return null; if (!view) return null;
const databaseLayout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const folderView = viewId === iidIndex ? meta : meta?.children?.find((v) => v.view_id === viewId);
const name = folderView?.name || view.get(YjsDatabaseKey.name) || t('untitled');
return ( return (
<TabsTrigger <DatabaseTabItem
key={viewId} key={viewId}
value={viewId} viewId={viewId}
id={`view-tab-${viewId}`} view={view}
data-testid={`view-tab-${viewId}`} meta={meta}
className={'min-w-[80px] max-w-[200px]'} iidIndex={iidIndex}
ref={(el) => { menuViewId={menuViewId}
if (el) { readOnly={!!readOnly}
tabRefs.current.set(viewId, el); visibleViewIds={visibleViewIds}
} else { onSetMenuViewId={setMenuViewId}
tabRefs.current.delete(viewId); onOpenDeleteModal={setDeleteConfirmOpen}
} onOpenRenameModal={setRenameViewId}
}} onReloadView={reloadView}
> setTabRef={setTabRef}
<TabLabel />
onPointerDown={(e) => {
// For left-click, let Radix UI tabs handle it via onValueChange
if (e.button === 0) {
return;
}
// For right-click and other buttons, prevent default and handle menu
e.preventDefault();
e.stopPropagation();
if (readOnly) return;
if (viewId !== menuViewId) {
setMenuViewId(viewId);
} else {
setMenuViewId(null);
}
}}
className={'flex items-center gap-1.5 overflow-hidden'}
>
<PageIcon
iconSize={16}
view={
folderView || {
layout:
databaseLayout === DatabaseViewLayout.Board
? ViewLayout.Board
: databaseLayout === DatabaseViewLayout.Calendar
? ViewLayout.Calendar
: ViewLayout.Grid,
}
}
className={'!h-5 !w-5 text-base leading-[1.3rem]'}
/>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<span
onContextMenu={(e) => {
e.preventDefault();
}}
className={'flex-1 truncate'}
>
{name || t('grid.title.placeholder')}
</span>
</TooltipTrigger>
<TooltipContent sideOffset={10} side={'right'}>
{name}
</TooltipContent>
</Tooltip>
</TabLabel>
<DropdownMenu
modal
onOpenChange={(open) => {
if (!open) {
setMenuViewId(null);
}
}}
open={menuViewId === viewId}
>
<DropdownMenuTrigger asChild>
<div className={'pointer-events-none absolute bottom-0 left-0 opacity-0'} />
</DropdownMenuTrigger>
<DropdownMenuContent
side={'bottom'}
align={'start'}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{menuView && (
<DatabaseViewActions
onClose={() => {
setMenuViewId(null);
}}
onOpenDeleteModal={(viewId: string) => {
setDeleteConfirmOpen(viewId);
}}
onOpenRenameModal={(viewId: string) => {
setRenameViewId(viewId);
}}
deleteDisabled={viewId === iidIndex && visibleViewIds.length > 1}
view={menuView}
onUpdatedIcon={reloadView}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</TabsTrigger>
); );
})} })}
</TabsList> </TabsList>
@@ -473,13 +347,17 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
data-testid="add-view-button" data-testid='add-view-button'
size={'icon'} size={'icon'}
variant={'ghost'} variant={'ghost'}
loading={addLoading} loading={addLoading}
className={'mx-1.5 mb-1.5 text-icon-secondary'} className={'mx-1.5 mb-1.5 text-icon-secondary'}
> >
{addLoading ? <Progress variant={'inherit'} /> : <PlusIcon className={'h-5 w-5'} />} {addLoading ? (
<Progress variant={'inherit'} />
) : (
<PlusIcon className={'h-5 w-5'} />
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -559,3 +437,5 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
); );
} }
); );
DatabaseTabs.displayName = 'DatabaseTabs';

View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
export const useTabScroller = () => {
const [scrollerContainer, setScrollerContainer] = useState<HTMLDivElement | null>(null);
const [showScrollRightButton, setShowScrollRightButton] = useState(false);
const [showScrollLeftButton, setShowScrollLeftButton] = useState(false);
const handleObserverScroller = useCallback(() => {
if (scrollerContainer) {
const scrollWidth = scrollerContainer.scrollWidth;
const clientWidth = scrollerContainer.clientWidth;
setShowScrollRightButton(
scrollWidth > clientWidth && scrollerContainer.scrollLeft + 1 < scrollWidth - clientWidth
);
setShowScrollLeftButton(scrollerContainer.scrollLeft > 5);
}
}, [scrollerContainer]);
useEffect(() => {
if (!scrollerContainer) return;
const onResize = () => {
handleObserverScroller();
};
// Initial call
onResize();
const observer = new ResizeObserver(onResize);
observer.observe(scrollerContainer);
return () => {
observer.disconnect();
};
}, [handleObserverScroller, scrollerContainer]);
const scrollLeft = useCallback(() => {
if (scrollerContainer) {
scrollerContainer.scrollTo({
left: scrollerContainer.scrollLeft - 200,
behavior: 'smooth',
});
}
}, [scrollerContainer]);
const scrollRight = useCallback(() => {
if (scrollerContainer) {
scrollerContainer.scrollTo({
left: scrollerContainer.scrollLeft + 200,
behavior: 'smooth',
});
}
}, [scrollerContainer]);
return {
setScrollerContainer,
showScrollLeftButton,
showScrollRightButton,
scrollLeft,
scrollRight,
handleObserverScroller,
};
};