import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { ActionIcon, Tooltip, Badge } from "@mantine/core"; import { Table } from "@tanstack/react-table"; import { IconSortAscending, IconFilter, IconEye, IconDownload, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; import { IBase, IBaseRow, IBaseView, ViewSortConfig, FilterCondition, FilterGroup, } from "@/features/base/types/base.types"; import { exportBaseToCsv } from "@/features/base/services/base-service"; import { ViewTabs } from "@/features/base/components/views/view-tabs"; import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config"; import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config"; import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility"; import { KanbanGroupByPicker } from "@/features/base/components/views/kanban/kanban-group-by-picker"; import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; import { useTranslation } from "react-i18next"; import classes from "@/features/base/styles/grid.module.css"; type BaseToolbarProps = { base: IBase; // Effective view — baseline merged with any local draft. Badge counts // and sort/filter popover seed data read from this. The real baseline // only enters via `onDraftSortsChange` / `onDraftFiltersChange` // callbacks defined by the parent. activeView: IBaseView | undefined; views: IBaseView[]; table: Table; onViewChange: (viewId: string) => void; onAddView?: () => void; onPersistViewConfig: () => void; onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; onDraftFiltersChange: (filter: FilterGroup | undefined) => void; }; export function BaseToolbar({ base, activeView, views, table, onViewChange, onAddView, onPersistViewConfig, onDraftSortsChange, onDraftFiltersChange, }: BaseToolbarProps) { const { t } = useTranslation(); const [sortOpened, setSortOpened] = useState(false); const [filterOpened, setFilterOpened] = useState(false); const [fieldsOpened, setFieldsOpened] = useState(false); const [exporting, setExporting] = useState(false); const toolbarRightRef = useRef(null); // Mantine ``'s built-in dismiss handlers don't fire reliably // for the toolbar popovers (same issue that drove the property menu to // use custom listeners in `grid-container.tsx`). Close any open toolbar // popover on outside mousedown AND on ESC. useEffect(() => { if (!sortOpened && !filterOpened && !fieldsOpened) return; const closeAll = () => { setSortOpened(false); setFilterOpened(false); setFieldsOpened(false); }; const mouseHandler = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; if (toolbarRightRef.current?.contains(target)) return; // Ignore clicks that land inside any Mantine popover dropdown // (role=dialog), any Select/Combobox dropdown (role=listbox, the // container; option elements have role=option), or anything // rendered into Mantine's shared portal node. Without these, a // nested Select inside the popover would close the parent. if (target.closest('[role="dialog"]')) return; if (target.closest('[role="listbox"]')) return; if (target.closest('[role="option"]')) return; if (target.closest("[data-mantine-shared-portal-node]")) return; closeAll(); }; const keyHandler = (e: KeyboardEvent) => { if (e.key === "Escape") closeAll(); }; const id = setTimeout(() => { document.addEventListener("mousedown", mouseHandler); }, 0); document.addEventListener("keydown", keyHandler); return () => { clearTimeout(id); document.removeEventListener("mousedown", mouseHandler); document.removeEventListener("keydown", keyHandler); }; }, [sortOpened, filterOpened, fieldsOpened]); const handleExport = useCallback(async () => { if (exporting) return; setExporting(true); try { await exportBaseToCsv(base.id); } catch (err) { notifications.show({ color: "red", message: t("Failed to export CSV"), }); } finally { setExporting(false); } }, [base.id, exporting, t]); const openToolbar = useCallback((panel: "sort" | "filter" | "fields") => { setSortOpened(panel === "sort" ? (v) => !v : false); setFilterOpened(panel === "filter" ? (v) => !v : false); setFieldsOpened(panel === "fields" ? (v) => !v : false); }, []); const sorts = activeView?.config?.sorts ?? []; // Stored view config uses the engine's filter tree. The popover edits // an AND-only flat list; we unwrap the top-level group's children when // reading and rewrap on save. const conditions = useMemo(() => { const filter = activeView?.config?.filter; if (!filter || filter.op !== "and") return []; return filter.children.filter( (c): c is FilterCondition => !("children" in c), ); }, [activeView?.config?.filter]); const hiddenFieldCount = useMemo(() => { const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length; }, [table, table.getState().columnVisibility]); const updateViewMutation = useUpdateViewMutation(); const isKanban = activeView?.type === "kanban"; const handleGroupByChange = useCallback( (propertyId: string) => { if (!activeView) return; updateViewMutation.mutate({ viewId: activeView.id, pageId: base.id, config: { ...activeView.config, groupByPropertyId: propertyId }, }); }, [activeView, base.id, updateViewMutation], ); const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { // Normalize empty to undefined so the draft hook can drop the `sorts` // axis (and remove its localStorage entry when both axes go clean). onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); }, [onDraftSortsChange], ); const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { // Wrap the AND-flat popover output into the engine's FilterGroup shape. // Pass `undefined` to drop the filter axis from the draft entirely. const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } : undefined; onDraftFiltersChange(filter); }, [onDraftFiltersChange], ); return (
{isKanban && ( )}
setFilterOpened(false)} conditions={conditions} properties={base.properties} onChange={handleFiltersChange} > 0 ? "blue" : "gray"} onClick={() => openToolbar("filter")} > {conditions.length > 0 && ( {conditions.length} )} setSortOpened(false)} sorts={sorts} properties={base.properties} onChange={handleSortsChange} > 0 ? "blue" : "gray"} onClick={() => openToolbar("sort")} > {sorts.length > 0 && ( {sorts.length} )} {!isKanban && ( setFieldsOpened(false)} table={table} properties={base.properties} onPersist={onPersistViewConfig} > 0 ? "blue" : "gray"} onClick={() => openToolbar("fields")} > {hiddenFieldCount > 0 && ( {hiddenFieldCount} )} )}
); }