import { useAtom, useSetAtom } from "jotai"; import { useSnackbar } from "notistack"; import { Menu, ListItem, ListItemIcon, ListItemText, Typography, Divider, } from "@mui/material"; import FilterIcon from "@mui/icons-material/FilterList"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockIcon from "@mui/icons-material/LockOutlined"; import VisibilityIcon from "@mui/icons-material/VisibilityOutlined"; import { Freeze as FreezeIcon, Unfreeze as UnfreezeIcon, CellResize as CellResizeIcon, ColumnPlusBefore as ColumnPlusBeforeIcon, ColumnPlusAfter as ColumnPlusAfterIcon, ColumnRemove as ColumnRemoveIcon, CloudLogs as LogsIcon, } from "@src/assets/icons"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import EditIcon from "@mui/icons-material/EditOutlined"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import EvalIcon from "@mui/icons-material/PlayCircleOutline"; import MenuContents, { IMenuContentsProps } from "./MenuContents"; import ColumnHeader from "@src/components/Table/Mock/Column"; import { projectScope, userSettingsAtom, updateUserSettingsAtom, confirmDialogAtom, rowyRunAtom, altPressAtom, } from "@src/atoms/projectScope"; import { tableScope, tableIdAtom, tableSettingsAtom, updateColumnAtom, deleteColumnAtom, tableSortsAtom, columnMenuAtom, columnModalAtom, tableFiltersPopoverAtom, tableNextPageAtom, tableSchemaAtom, cloudLogFiltersAtom, tableModalAtom, } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; import { analytics, logEvent } from "@src/analytics"; import { formatSubTableName, getTableBuildFunctionPathname, getTableSchemaPath, } from "@src/utils/table"; import { runRoutes } from "@src/constants/runRoutes"; import { useSnackLogContext } from "@src/contexts/SnackLogContext"; import useSaveTableSorts from "@src/components/Table/ColumnHeader/useSaveTableSorts"; export interface IMenuModalProps { name: string; fieldName: string; type: FieldType; open: boolean; config: Record; handleClose: () => void; handleSave: ( fieldName: string, config: Record, onSuccess?: Function ) => void; } export interface IColumnMenuProps { canAddColumns: boolean; canEditColumns: boolean; canDeleteColumns: boolean; } export default function ColumnMenu({ canAddColumns, canEditColumns, canDeleteColumns, }: IColumnMenuProps) { const [userSettings] = useAtom(userSettingsAtom, projectScope); const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope); const [rowyRun] = useAtom(rowyRunAtom, projectScope); const [tableId] = useAtom(tableIdAtom, tableScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const updateColumn = useSetAtom(updateColumnAtom, tableScope); const deleteColumn = useSetAtom(deleteColumnAtom, tableScope); const [tableSorts, setTableSorts] = useAtom(tableSortsAtom, tableScope); const [columnMenu, setColumnMenu] = useAtom(columnMenuAtom, tableScope); const openColumnModal = useSetAtom(columnModalAtom, tableScope); const openTableFiltersPopover = useSetAtom( tableFiltersPopoverAtom, tableScope ); const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const setModal = useSetAtom(tableModalAtom, tableScope); const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope); const snackLogContext = useSnackLogContext(); const [altPress] = useAtom(altPressAtom, projectScope); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const triggerSaveTableSorts = useSaveTableSorts(canEditColumns); if (!columnMenu) return null; const { column, anchorEl } = columnMenu; if (column.type === FieldType.last) return null; const handleClose = () => { setColumnMenu({ column, anchorEl: null }); setTimeout(() => setColumnMenu(null), 300); }; const isConfigurable = Boolean( getFieldProp("settings", column?.type) || getFieldProp("initializable", column?.type) ); const _sortKey = getFieldProp("sortKey", (column as any).type); const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; const isSorted = tableSorts[0]?.key === sortKey; const isAsc = isSorted && tableSorts[0]?.direction === "asc"; const userDocHiddenFields = userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? []; let referencedColumns: string[] = []; let referencedExtensions: string[] = []; Object.entries(tableSchema?.columns ?? {}).forEach(([key, c], index) => { if ( c.config?.listenerFields?.includes(column.key) || c.config?.requiredFields?.includes(column.key) ) { referencedColumns.push(c.name); } }); tableSchema?.extensionObjects?.forEach((extension) => { if (extension.requiredFields.includes(column.key)) { referencedExtensions.push(extension.name); } }); const requireRebuild = referencedColumns.length || referencedExtensions.length; const handleDeleteColumn = () => { deleteColumn(column.key); if (requireRebuild) { snackLogContext.requestSnackLog(); rowyRun({ route: runRoutes.buildFunction, body: { tablePath: tableSettings.collection, // pathname must match old URL format pathname: getTableBuildFunctionPathname( tableSettings.id, tableSettings.tableType ), tableConfigPath: getTableSchemaPath(tableSettings), }, }); logEvent(analytics, "deployed_extensions"); } logEvent(analytics, "delete_column", { type: column.type }); handleClose(); }; const localViewActions: IMenuContentsProps["menuItems"] = [ { type: "subheader", key: "subLocalView" }, { key: "sortDesc", label: "Sort: descending", activeLabel: "Remove sort: descending", icon: , onClick: () => { setTableSorts( isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }] ); triggerSaveTableSorts( isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }] ); handleClose(); }, active: isSorted && !isAsc, disabled: column.type === FieldType.id, }, { key: "sortAsc", label: "Sort: ascending", activeLabel: "Remove sort: ascending", icon: , onClick: () => { setTableSorts( isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }] ); triggerSaveTableSorts( isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }] ); handleClose(); }, active: isSorted && isAsc, disabled: column.type === FieldType.id, }, { key: "hide", label: "Hide", icon: , onClick: () => { if (updateUserSettings) updateUserSettings({ tables: { [formatSubTableName(tableId)]: { hiddenFields: [...userDocHiddenFields, column.key], }, }, }); handleClose(); }, disabled: !updateUserSettings, }, { key: "filter", label: "Filter…", icon: , onClick: () => { openTableFiltersPopover({ defaultQuery: { key: column.fieldName, operator: getFieldProp( "filter", column.type === FieldType.derivative ? column.config?.renderFieldType : column.type )!.operators[0]?.value || "==", value: "", }, }); handleClose(); }, active: column.hidden, disabled: !getFieldProp( "filter", column.type === FieldType.derivative ? column.config?.renderFieldType : column.type ), }, ]; const configActions: IMenuContentsProps["menuItems"] = [ { type: "subheader", key: "subActionsConfig" }, { label: "Lock", key: "lock", activeLabel: "Unlock", icon: , activeIcon: , onClick: () => { updateColumn({ key: column.key, config: { editable: !column.editable }, }); handleClose(); }, active: column.editable === false, }, { label: "Disable resize", key: "disableResize", activeLabel: "Enable resize", icon: , onClick: () => { updateColumn({ key: column.key, config: { resizable: !column.resizable }, }); handleClose(); }, active: !column.resizable, }, { label: "Freeze", key: "freeze", activeLabel: "Unfreeze", icon: , activeIcon: , onClick: () => { updateColumn({ key: column.key, config: { fixed: !column.fixed } }); handleClose(); }, active: column.fixed, }, // { type: "subheader" }, { label: "Rename…", key: "rename", icon: , onClick: () => { openColumnModal({ type: "name", columnKey: column.key }); handleClose(); }, }, { label: `Edit type: ${getFieldProp("name", column.type)}…`, key: "editType", // This is based on the cell type icon: getFieldProp("icon", column.type), onClick: () => { openColumnModal({ type: "type", columnKey: column.key }); handleClose(); }, }, { label: `Column config…`, key: "columConfig", icon: , onClick: () => { openColumnModal({ type: "config", columnKey: column.key }); handleClose(); }, disabled: !isConfigurable, }, ]; if ( column?.config?.defaultValue?.type === "dynamic" || [FieldType.action, FieldType.derivative, FieldType.connector].includes( column.type ) ) { configActions.push({ key: "logs", label: altPress ? "Logs" : "Logs…", icon: , onClick: () => { setModal("cloudLogs"); setCloudLogFilters({ type: "column", timeRange: { type: "days", value: 7 }, column: [column.key], }); }, }); } // TODO: Generalize const handleEvaluateAll = async () => { try { handleClose(); const evaluatingSnackKey = enqueueSnackbar(`Evaluating “${column.key}”…`); const result = await rowyRun({ route: runRoutes.evaluateDerivative, body: { collectionPath: tableSettings.collection, schemaDocPath: getTableSchemaPath(tableSettings), columnKey: column.key, }, }); closeSnackbar(evaluatingSnackKey); if (result.success === false) { enqueueSnackbar(result.message, { variant: "error" }); } else { enqueueSnackbar(`Column “${column.key}” evaluated`, { variant: "success", }); } } catch (error) { enqueueSnackbar(`Failed: ${error}`, { variant: "error" }); } }; const derivativeActions: IMenuContentsProps["menuItems"] = [ { type: "subheader", key: "sub-derivative" }, { key: "evaluateAll", label: altPress ? "Evaluate all" : "Evaluate all…", icon: , onClick: altPress ? handleEvaluateAll : () => confirm({ title: "Evaluate all?", body: ( <> All documents in {tableSettings.collection} collection will be evaluated.
{" "} {tableNextPage.available && "For large collections this will take a while and might incur significant firestore costs."} ), handleConfirm: handleEvaluateAll, confirm: "Evaluate", }), }, ]; const columnActions: IMenuContentsProps["menuItems"] = [ { type: "subheader", key: "subActions" }, { label: "Insert to the left…", key: "insertLeft", icon: , onClick: () => { openColumnModal({ type: "new", index: column.index - 1 }); handleClose(); }, disabled: !canAddColumns, }, { label: "Insert to the right…", key: "insertRight", icon: , onClick: () => { openColumnModal({ type: "new", index: column.index + 1 }); handleClose(); }, disabled: !canAddColumns, }, { label: `Delete column${altPress ? "" : "…"}`, key: "delete", icon: , onClick: altPress ? handleDeleteColumn : () => { return confirm({ title: "Delete column?", body: ( <> Only the column configuration will be deleted. No data will be deleted. This cannot be undone. Key: {column.key} {requireRebuild ? ( <> {referencedColumns.length ? ( This column will be removed as a dependency of the following columns:{" "} {referencedColumns.join(", ")} ) : null} {referencedExtensions.length ? ( This column will be removed as a dependency from the following Extensions:{" "} {referencedExtensions.join(", ")} ) : null} You need to re-deploy this table’s cloud function. ) : null} ), confirm: requireRebuild ? "Delete & re-deploy" : "Delete", confirmColor: "error", handleConfirm: handleDeleteColumn, }); }, color: "error" as "error", disabled: !canDeleteColumns, }, ]; let menuItems = [...localViewActions]; if (canEditColumns) { menuItems.push.apply(menuItems, configActions); if (column.type === FieldType.derivative) { menuItems.push.apply(menuItems, derivativeActions); } } if (canAddColumns || canDeleteColumns) { menuItems.push.apply(menuItems, columnActions); } return ( {getFieldProp("icon", column.type)} Key: {column.key} } primaryTypographyProps={{ variant: "subtitle2" }} secondaryTypographyProps={{ variant: "caption" }} sx={{ m: 0, minHeight: 40, "& > *": { userSelect: "none" } }} /> ); }