diff --git a/src/atoms/globalScope/ui.ts b/src/atoms/globalScope/ui.ts index ebe27859..6230aafc 100644 --- a/src/atoms/globalScope/ui.ts +++ b/src/atoms/globalScope/ui.ts @@ -1,8 +1,12 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; +import { atomWithStorage, atomWithHash } from "jotai/utils"; -import { DialogProps, ButtonProps } from "@mui/material"; -import { TableSettings, TableSchema } from "@src/types/table"; +import type { DialogProps, ButtonProps, PopoverProps } from "@mui/material"; +import type { + TableSettings, + TableSchema, + ColumnConfig, +} from "@src/types/table"; import { getTableSchemaAtom } from "./project"; /** Nav open state stored in local storage. */ @@ -154,6 +158,46 @@ export const tableDescriptionDismissedAtom = atomWithStorage( [] ); +/** + * Open table column menu. Set to `null` to close. + * + * @example Basic usage: + * ``` + * const openColumnMenu = useSetAtom(columnMenuAtom, globalScope); + * openColumnMenu({ column, anchorEl: ... }); + * ``` + * + * @example Close: + * ``` + * openColumnMenu(null) + * ``` + */ +export const columnMenuAtom = atom<{ + column: ColumnConfig; + anchorEl: PopoverProps["anchorEl"]; +} | null>(null); + +/** + * Opens a table column modal. Set to `null` to close. + * Modals: new column, name change, type change, column settings. + * + * @example Basic usage: + * ``` + * const openColumnModal = useSetAtom(columnModalAtom, globalScope); + * openColumnModal({ type: "...", column }); + * ``` + * + * @example Close: + * ``` + * openColumnModal(null) + * ``` + */ +export const columnModalAtom = atomWithHash<{ + type: "new" | "name" | "type" | "config"; + columnKey?: string; + index?: number; +} | null>("columnModal", null, { replaceState: true }); + /** Store current JSON editor view */ export const jsonEditorAtom = atomWithStorage<"tree" | "code">( "__ROWY__JSON_EDITOR", diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index 87fb7706..9b35c2ce 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -39,11 +39,18 @@ export const tableSchemaAtom = atom({}); export const updateTableSchemaAtom = atom< UpdateDocFunction | undefined >(undefined); -/** Store the table columns as an ordered array */ +/** + * Store the table columns as an ordered array. + * Puts frozen columns at the start, then sorts by ascending index. + */ export const tableColumnsOrderedAtom = atom((get) => { const tableSchema = get(tableSchemaAtom); if (!tableSchema || !tableSchema.columns) return []; - return orderBy(Object.values(tableSchema?.columns ?? {}), "index"); + return orderBy( + Object.values(tableSchema?.columns ?? {}), + [(c) => Boolean(c.fixed), "index"], + ["desc", "asc"] + ); }); /** Reducer function to convert from array of columns to columns object */ export const tableColumnsReducer = ( diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx new file mode 100644 index 00000000..cdb2475d --- /dev/null +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -0,0 +1,319 @@ +import { useAtom, useSetAtom } from "jotai"; + +import { + Menu, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} 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 FreezeIcon from "@src/assets/icons/Freeze"; +import UnfreezeIcon from "@src/assets/icons/Unfreeze"; +import CellResizeIcon from "@src/assets/icons/CellResize"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import EditIcon from "@mui/icons-material/EditOutlined"; +// import ReorderIcon from "@mui/icons-material/Reorder"; +import SettingsIcon from "@mui/icons-material/SettingsOutlined"; +import ColumnPlusBeforeIcon from "@src/assets/icons/ColumnPlusBefore"; +import ColumnPlusAfterIcon from "@src/assets/icons/ColumnPlusAfter"; +import ColumnRemoveIcon from "@src/assets/icons/ColumnRemove"; + +import MenuContents from "./MenuContents"; +import ColumnHeader from "@src/components/Table/Column"; + +import { + globalScope, + userSettingsAtom, + updateUserSettingsAtom, + confirmDialogAtom, + columnMenuAtom, + columnModalAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableIdAtom, + updateColumnAtom, + deleteColumnAtom, + tableOrdersAtom, +} from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; +import { analytics, logEvent } from "@src/analytics"; +import useKeyPress from "@src/hooks/useKeyPress"; +import { formatSubTableName } from "@src/utils/table"; + +export interface IMenuModalProps { + name: string; + fieldName: string; + type: FieldType; + + open: boolean; + config: Record; + + handleClose: () => void; + handleSave: ( + fieldName: string, + config: Record, + onSuccess?: Function + ) => void; +} + +export default function ColumnMenu() { + const [userSettings] = useAtom(userSettingsAtom, globalScope); + const [updateUserSettings] = useAtom(updateUserSettingsAtom, globalScope); + const [columnMenu, setColumnMenu] = useAtom(columnMenuAtom, globalScope); + const openColumnModal = useSetAtom(columnModalAtom, globalScope); + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const deleteColumn = useSetAtom(deleteColumnAtom, tableScope); + const [tableOrders, setTableOrders] = useAtom(tableOrdersAtom, tableScope); + const altPress = useKeyPress("Alt"); + + 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 = tableOrders[0]?.key === sortKey; + const isAsc = isSorted && tableOrders[0]?.direction === "asc"; + + const userDocHiddenFields = + userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? []; + + const handleDeleteColumn = () => { + deleteColumn(column.key); + logEvent(analytics, "delete_column", { type: column.type }); + handleClose(); + }; + + const menuItems = [ + { type: "subheader", label: "Your view" }, + { + label: "Sort: descending", + activeLabel: "Remove sort: descending", + icon: , + onClick: () => { + setTableOrders( + isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }] + ); + handleClose(); + }, + active: isSorted && !isAsc, + disabled: column.type === FieldType.id, + }, + { + label: "Sort: ascending", + activeLabel: "Remove sort: ascending", + icon: , + onClick: () => { + setTableOrders( + isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }] + ); + handleClose(); + }, + active: isSorted && isAsc, + disabled: column.type === FieldType.id, + }, + { + label: "Hide", + icon: , + onClick: () => { + if (updateUserSettings) + updateUserSettings({ + tables: { + [formatSubTableName(tableId)]: { + hiddenFields: [...userDocHiddenFields, column.key], + }, + }, + }); + handleClose(); + }, + disabled: !updateUserSettings, + }, + { + label: "Filter…", + icon: , + // FIXME: onClick: () => { + // actions.update(column.key, { hidden: !column.hidden }); + // handleClose(); + // }, + active: column.hidden, + disabled: true, + }, + { type: "subheader", label: "All users’ views" }, + { + label: "Lock", + activeLabel: "Unlock", + icon: , + activeIcon: , + onClick: () => { + updateColumn({ + key: column.key, + config: { editable: !column.editable }, + }); + handleClose(); + }, + active: !column.editable, + }, + { + label: "Disable resize", + activeLabel: "Enable resize", + icon: , + onClick: () => { + updateColumn({ + key: column.key, + config: { resizable: !column.resizable }, + }); + handleClose(); + }, + active: !column.resizable, + }, + { + label: "Freeze", + activeLabel: "Unfreeze", + icon: , + activeIcon: , + onClick: () => { + updateColumn({ key: column.key, config: { fixed: !column.fixed } }); + handleClose(); + }, + active: column.fixed, + }, + { type: "subheader", label: "Add column" }, + { + label: "Add new to left…", + icon: , + onClick: () => { + openColumnModal({ type: "new", index: column.index - 1 }); + handleClose(); + }, + }, + { + label: "Add new to right…", + icon: , + onClick: () => { + openColumnModal({ type: "new", index: column.index + 1 }); + handleClose(); + }, + }, + { type: "subheader", label: "Configure" }, + { + label: "Rename…", + icon: , + onClick: () => { + openColumnModal({ type: "name", columnKey: column.key }); + handleClose(); + }, + }, + { + label: `Edit type: ${getFieldProp("name", column.type)}…`, + // This is based on the cell type + icon: getFieldProp("icon", column.type), + onClick: () => { + openColumnModal({ type: "type", columnKey: column.key }); + handleClose(); + }, + }, + { + label: `Column config…`, + icon: , + onClick: () => { + openColumnModal({ type: "config", columnKey: column.key }); + handleClose(); + }, + disabled: !isConfigurable, + }, + // { + // label: "Re-order", + // icon: , + // onClick: () => alert("REORDER"), + // }, + + // { + // label: "Hide for everyone", + // activeLabel: "Show", + // icon: , + // activeIcon: , + // onClick: () => { + // actions.update(column.key, { hidden: !column.hidden }); + // handleClose(); + // }, + // active: column.hidden, + // color: "error" as "error", + // }, + { + label: `Delete column${altPress ? "" : "…"}`, + icon: , + onClick: altPress + ? handleDeleteColumn + : () => + confirm({ + title: "Delete column?", + body: ( + <> + + Only the column configuration will be deleted. No data will + be deleted. This cannot be undone. + + + + Key: {column.key} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleDeleteColumn, + }), + color: "error" as "error", + }, + ]; + + return ( + + + + {getFieldProp("icon", column.type)} + + + Key: {column.key} + + } + primaryTypographyProps={{ variant: "subtitle2" }} + secondaryTypographyProps={{ variant: "caption" }} + sx={{ m: 0, minHeight: 40, "& > *": { userSelect: "none" } }} + /> + + + + + ); +} diff --git a/src/components/ColumnMenu/MenuContents.tsx b/src/components/ColumnMenu/MenuContents.tsx new file mode 100644 index 00000000..d442b77d --- /dev/null +++ b/src/components/ColumnMenu/MenuContents.tsx @@ -0,0 +1,51 @@ +import { Fragment } from "react"; + +import { MenuItem, ListItemIcon, ListSubheader, Divider } from "@mui/material"; + +export interface IMenuContentsProps { + menuItems: { + type?: string; + label?: string; + activeLabel?: string; + icon?: JSX.Element; + activeIcon?: JSX.Element; + onClick?: () => void; + active?: boolean; + color?: "error"; + disabled?: boolean; + }[]; +} + +export default function MenuContents({ menuItems }: IMenuContentsProps) { + return ( + <> + {menuItems.map((item, index) => { + if (item.type === "subheader") + return ( + + + {item.label && ( + {item.label} + )} + + ); + + let icon: JSX.Element = item.icon ?? <>; + if (item.active && !!item.activeIcon) icon = item.activeIcon; + + return ( + + {icon} + {item.active ? item.activeLabel : item.label} + + ); + })} + + ); +} diff --git a/src/components/ColumnMenu/index.ts b/src/components/ColumnMenu/index.ts new file mode 100644 index 00000000..8d2f8078 --- /dev/null +++ b/src/components/ColumnMenu/index.ts @@ -0,0 +1,2 @@ +export * from "./ColumnMenu"; +export { default } from "./ColumnMenu"; diff --git a/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx b/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx new file mode 100644 index 00000000..6621cde7 --- /dev/null +++ b/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx @@ -0,0 +1,218 @@ +import { useState, Suspense, useMemo, createElement } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { set } from "lodash-es"; +import { ErrorBoundary } from "react-error-boundary"; +import { IColumnModalProps } from "@src/components/ColumnModals"; + +import { Typography, Stack } from "@mui/material"; + +import Modal from "@src/components/Modal"; +import { getFieldProp } from "@src/components/fields"; +import DefaultValueInput from "./DefaultValueInput"; +import { InlineErrorFallback } from "@src/components/ErrorFallback"; +import Loading from "@src/components/Loading"; + +import { + globalScope, + rowyRunAtom, + confirmDialogAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + updateColumnAtom, +} from "@src/atoms/tableScope"; +import { useSnackLogContext } from "@src/contexts/SnackLogContext"; +import { FieldType } from "@src/constants/fields"; +import { runRoutes } from "@src/constants/runRoutes"; +import { useSnackbar } from "notistack"; +import { getSchemaPath } from "@src/utils/table"; + +export default function ColumnConfigModal({ + handleClose, + column, +}: IColumnModalProps) { + const [rowyRun] = useAtom(rowyRunAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const snackLogContext = useSnackLogContext(); + + const [showRebuildPrompt, setShowRebuildPrompt] = useState(false); + const [newConfig, setNewConfig] = useState(column.config ?? {}); + const customFieldSettings = getFieldProp("settings", column.type); + const settingsValidator = getFieldProp("settingsValidator", column.type); + const initializable = getFieldProp("initializable", column.type); + + const rendedFieldSettings = useMemo( + () => + [FieldType.derivative, FieldType.aggregate].includes(column.type) && + newConfig.renderFieldType + ? getFieldProp("settings", newConfig.renderFieldType) + : null, + [newConfig.renderFieldType, column.type] + ); + + const [errors, setErrors] = useState({}); + + const validateSettings = () => { + if (settingsValidator) { + const errors = settingsValidator(newConfig); + setErrors(errors); + return errors; + } + setErrors({}); + return {}; + }; + + const handleChange = (key: string) => (update: any) => { + if ( + showRebuildPrompt === false && + (key.includes("defaultValue") || column.type === FieldType.derivative) && + column.config?.[key] !== update + ) { + setShowRebuildPrompt(true); + } + const updatedConfig = set({ ...newConfig }, key, update); + setNewConfig(updatedConfig); + validateSettings(); + }; + + return ( + }> + <> + {initializable && ( + <> +
+ {/* top margin fixes visual bug */} + + + +
+ + )} + + {customFieldSettings && ( + + {createElement(customFieldSettings, { + config: newConfig, + onChange: handleChange, + fieldName: column.fieldName, + onBlur: validateSettings, + errors, + })} + + )} + + {rendedFieldSettings && ( + + + Rendered field config + + {createElement(rendedFieldSettings, { + config: newConfig, + onChange: handleChange, + onBlur: validateSettings, + errors, + })} + + )} + {/* { + + } */} + + + } + actions={{ + primary: { + onClick: async () => { + const errors = validateSettings(); + if (Object.keys(errors).length > 0) { + confirm({ + title: "Invalid settings", + body: ( + <> + Please fix the following settings: +
    + {Object.entries(errors).map(([key, message]) => ( +
  • + <> + {key}: {message} + +
  • + ))} +
+ + ), + confirm: "Fix", + hideCancel: true, + handleConfirm: () => {}, + }); + return; + } + + const savingSnack = enqueueSnackbar("Saving changes…"); + + await updateColumn({ + key: column.key, + config: { config: newConfig }, + }); + + if (showRebuildPrompt) { + confirm({ + title: "Deploy changes?", + body: "You need to re-deploy this table’s cloud function to apply the changes you made. You can also re-deploy later.", + confirm: "Deploy", + cancel: "Later", + handleConfirm: async () => { + if (!rowyRun) return; + snackLogContext.requestSnackLog(); + rowyRun({ + route: runRoutes.buildFunction, + body: { + tablePath: tableSettings.collection, + pathname: window.location.pathname, + tableConfigPath: getSchemaPath(tableSettings), + }, + }); + }, + }); + } + + closeSnackbar(savingSnack); + enqueueSnackbar("Changes saved"); + + handleClose(); + setShowRebuildPrompt(false); + }, + children: "Update", + }, + secondary: { + onClick: handleClose, + children: "Cancel", + }, + }} + /> + ); +} diff --git a/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx new file mode 100644 index 00000000..fd025967 --- /dev/null +++ b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx @@ -0,0 +1,232 @@ +import { lazy, Suspense, createElement } from "react"; +import { useForm } from "react-hook-form"; +import { useAtom } from "jotai"; + +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { Typography, TextField, MenuItem, ListItemText } from "@mui/material"; + +import { getFieldProp } from "@src/components/fields"; +import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton"; +import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper"; +import FormAutosave from "./FormAutosave"; +import { FieldType } from "@src/constants/fields"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; + +/* eslint-disable import/no-webpack-loader-syntax */ +import defaultValueDefs from "!!raw-loader!./defaultValue.d.ts"; +import { + globalScope, + compatibleRowyRunVersionAtom, + projectSettingsAtom, +} from "@src/atoms/globalScope"; +import { ColumnConfig } from "@src/types/table"; + +const CodeEditorComponent = lazy( + () => + import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */) +); + +const diagnosticsOptions = { + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: true, +}; + +interface ICodeEditorProps { + type: FieldType; + column: ColumnConfig; + handleChange: (key: string) => (update: string | undefined) => void; +} + +function CodeEditor({ type, column, handleChange }: ICodeEditorProps) { + const [compatibleRowyRunVersion] = useAtom( + compatibleRowyRunVersionAtom, + globalScope + ); + + const functionBodyOnly = compatibleRowyRunVersion!({ maxVersion: "1.3.10" }); + const returnType = getFieldProp("dataType", type) ?? "any"; + + let dynamicValueFn = ""; + if (functionBodyOnly) { + dynamicValueFn = column.config?.defaultValue?.script || ""; + } else if (column.config?.defaultValue?.dynamicValueFn) { + dynamicValueFn = column.config?.defaultValue?.dynamicValueFn; + } else if (column.config?.defaultValue?.script) { + dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ + ${column.config?.defaultValue.script} + }`; + } else { + dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ + // Write your default value code here + // for example: + // generate random hex color + // const color = "#" + Math.floor(Math.random() * 16777215).toString(16); + // return color; + // checkout the documentation for more info: https://docs.rowy.io/how-to/default-values#dynamic + }`; + } + + return ( + ` + ), + ]} + onChange={handleChange( + functionBodyOnly ? "defaultValue.script" : "defaultValue.dynamicValueFn" + )} + /> + ); +} + +export interface IDefaultValueInputProps { + handleChange: (key: string) => (update: any) => void; + column: ColumnConfig; +} + +export default function DefaultValueInput({ + handleChange, + column, +}: IDefaultValueInputProps) { + const [projectSettings] = useAtom(projectSettingsAtom, globalScope); + + const _type = + column.type !== FieldType.derivative + ? column.type + : column.config?.renderFieldType ?? FieldType.shortText; + const customFieldInput = getFieldProp("SideDrawerField", _type); + const { control } = useForm({ + mode: "onBlur", + defaultValues: { + [column.fieldName]: + column.config?.defaultValue?.value ?? + getFieldProp("initialValue", _type), + }, + }); + + return ( + <> + handleChange("defaultValue.type")(e.target.value)} + fullWidth + sx={{ mb: 1 }} + > + + + + + + Initialise as null. + + } + /> + + + + + + + Dynamic —{" "} + + Requires Rowy Run setup + + + ) + } + secondary="Write code to set the default value using Rowy Run" + /> + + + {(!column.config?.defaultValue || + column.config?.defaultValue.type === "undefined") && ( + <> + + Make this column required + + The row will not be created or updated unless all required + values are set. + + + } + control={ + handleChange("required")(e.target.checked)} + name="required" + /> + } + /> + + )} + {column.config?.defaultValue?.type === "static" && customFieldInput && ( +
+ + handleChange("defaultValue.value")(values[column.fieldName]) + } + /> + + {createElement(customFieldInput, { + column, + control, + docRef: {}, + disabled: false, + })} + + )} + + {column.config?.defaultValue?.type === "dynamic" && ( + <> + + }> + + + + )} + + ); +} diff --git a/src/components/ColumnModals/ColumnConfigModal/FormAutosave.tsx b/src/components/ColumnModals/ColumnConfigModal/FormAutosave.tsx new file mode 100644 index 00000000..02490492 --- /dev/null +++ b/src/components/ColumnModals/ColumnConfigModal/FormAutosave.tsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { useDebounce } from "use-debounce"; +import { isEqual } from "lodash-es"; + +import { Control, useWatch } from "react-hook-form"; + +export interface IAutosaveProps { + control: Control; + handleSave: (values: any) => void; + debounce?: number; +} + +export default function FormAutosave({ + control, + handleSave, + debounce = 1000, +}: IAutosaveProps) { + const values = useWatch({ control }); + + const [debouncedValue] = useDebounce(values, debounce, { + equalityFn: isEqual, + }); + + useEffect(() => { + handleSave(debouncedValue); + }, [debouncedValue]); + + return null; +} diff --git a/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts b/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts new file mode 100644 index 00000000..0e50bb2d --- /dev/null +++ b/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts @@ -0,0 +1,8 @@ +type DefaultValueContext = { + row: Row; + ref: FirebaseFirestore.DocumentReference; + storage: firebasestorage.Storage; + db: FirebaseFirestore.Firestore; + auth: firebaseauth.BaseAuth; +}; +type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE"; diff --git a/src/components/ColumnModals/ColumnConfigModal/index.ts b/src/components/ColumnModals/ColumnConfigModal/index.ts new file mode 100644 index 00000000..56f08de2 --- /dev/null +++ b/src/components/ColumnModals/ColumnConfigModal/index.ts @@ -0,0 +1,2 @@ +export * from "./ColumnConfig"; +export { default } from "./ColumnConfig"; diff --git a/src/components/ColumnModals/ColumnModals.tsx b/src/components/ColumnModals/ColumnModals.tsx new file mode 100644 index 00000000..fef7eabd --- /dev/null +++ b/src/components/ColumnModals/ColumnModals.tsx @@ -0,0 +1,41 @@ +import { useAtom } from "jotai"; + +import NewColumnModal from "./NewColumnModal"; +import NameChangeModal from "./NameChangeModal"; +import TypeChangeModal from "./TypeChangeModal"; +import ColumnConfigModal from "./ColumnConfigModal"; + +import { globalScope, columnModalAtom } from "@src/atoms/globalScope"; +import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; +import { ColumnConfig } from "@src/types/table"; + +export interface IColumnModalProps { + handleClose: () => void; + column: ColumnConfig; +} + +export default function ColumnModals() { + const [columnModal, setColumnModal] = useAtom(columnModalAtom, globalScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + if (!columnModal) return null; + + const handleClose = () => setColumnModal(null); + + if (columnModal.type === "new") + return ; + + const column = tableSchema.columns?.[columnModal.columnKey ?? ""]; + if (!column) return null; + + if (columnModal.type === "name") + return ; + + if (columnModal.type === "type") + return ; + + if (columnModal.type === "config") + return ; + + return null; +} diff --git a/src/components/ColumnModals/FieldsDropdown.tsx b/src/components/ColumnModals/FieldsDropdown.tsx new file mode 100644 index 00000000..8257292c --- /dev/null +++ b/src/components/ColumnModals/FieldsDropdown.tsx @@ -0,0 +1,84 @@ +import MultiSelect from "@rowy/multiselect"; +import { ListItemIcon } from "@mui/material"; + +import { FIELDS } from "@src/components/fields"; +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; + +export interface IFieldsDropdownProps { + value: FieldType | ""; + onChange: (value: FieldType) => void; + hideLabel?: boolean; + label?: string; + options?: FieldType[]; + [key: string]: any; +} + +/** + * Returns dropdown component of all available types + */ +export default function FieldsDropdown({ + value, + onChange, + hideLabel = false, + label, + options: optionsProp, + ...props +}: IFieldsDropdownProps) { + const fieldTypesToDisplay = optionsProp + ? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1) + : FIELDS; + const options = fieldTypesToDisplay.map((fieldConfig) => ({ + label: fieldConfig.name, + value: fieldConfig.type, + })); + + return ( + + getFieldProp("group", option.value), + }, + } as any)} + itemRenderer={(option) => ( + <> + + {getFieldProp("icon", option.value as FieldType)} + + {option.label} + + )} + label={label || "Field type"} + labelPlural="field types" + TextFieldProps={{ + hiddenLabel: hideLabel, + helperText: value && getFieldProp("description", value), + ...props.TextFieldProps, + SelectProps: { + displayEmpty: true, + renderValue: () => ( + <> + + {getFieldProp("icon", value as FieldType)} + + {getFieldProp("name", value as FieldType)} + + ), + ...props.TextFieldProps?.SelectProps, + }, + }} + /> + ); +} diff --git a/src/components/ColumnModals/NameChangeModal.tsx b/src/components/ColumnModals/NameChangeModal.tsx new file mode 100644 index 00000000..b4a5ae01 --- /dev/null +++ b/src/components/ColumnModals/NameChangeModal.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { useSetAtom } from "jotai"; +import { IColumnModalProps } from "."; + +import { TextField } from "@mui/material"; +import Modal from "@src/components/Modal"; + +import { tableScope, updateColumnAtom } from "@src/atoms/tableScope"; + +export default function NameChangeModal({ + handleClose, + column, +}: IColumnModalProps) { + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const [newName, setName] = useState(column.name); + + return ( + setName(e.target.value)} + /> + } + actions={{ + primary: { + onClick: () => { + updateColumn({ key: column.key, config: { name: newName } }); + handleClose(); + }, + children: "Update", + }, + secondary: { + onClick: handleClose, + children: "Cancel", + }, + }} + /> + ); +} diff --git a/src/components/ColumnModals/NewColumnModal.tsx b/src/components/ColumnModals/NewColumnModal.tsx new file mode 100644 index 00000000..63377747 --- /dev/null +++ b/src/components/ColumnModals/NewColumnModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { camelCase } from "lodash-es"; +import { IColumnModalProps } from "."; + +import { TextField, Typography, Button } from "@mui/material"; + +import Modal from "@src/components/Modal"; +import FieldsDropdown from "./FieldsDropdown"; + +import { + globalScope, + columnModalAtom, + updateTableAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + addColumnAtom, +} from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; +import { analytics, logEvent } from "@src/analytics"; + +const AUDIT_FIELD_TYPES = [ + FieldType.createdBy, + FieldType.createdAt, + FieldType.updatedBy, + FieldType.updatedAt, +]; + +export default function NewColumnModal({ + handleClose, +}: Pick) { + const [columnModal, setColumnModal] = useAtom(columnModalAtom, globalScope); + const [updateTable] = useAtom(updateTableAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const addColumn = useSetAtom(addColumnAtom, tableScope); + + const [columnLabel, setColumnLabel] = useState(""); + const [fieldKey, setFieldKey] = useState(""); + const [type, setType] = useState(FieldType.shortText); + const requireConfiguration = getFieldProp("requireConfiguration", type); + + const isAuditField = AUDIT_FIELD_TYPES.includes(type); + + const handleTypeChange = (type: FieldType) => { + setType(type); + switch (type) { + case FieldType.id: + setColumnLabel("ID"); + setFieldKey("id"); + break; + case FieldType.createdBy: + setColumnLabel("Created By"); + setFieldKey(tableSettings.auditFieldCreatedBy || "_createdBy"); + break; + case FieldType.updatedBy: + setColumnLabel("Updated By"); + setFieldKey(tableSettings.auditFieldUpdatedBy || "_updatedBy"); + break; + case FieldType.createdAt: + setColumnLabel("Created At"); + setFieldKey( + (tableSettings.auditFieldCreatedBy || "_createdBy") + ".timestamp" + ); + break; + case FieldType.updatedAt: + setColumnLabel("Updated At"); + setFieldKey( + (tableSettings.auditFieldUpdatedBy || "_updatedBy") + ".timestamp" + ); + break; + } + }; + + return ( + +
+ { + setColumnLabel(e.target.value); + if (type !== FieldType.id && !isAuditField) { + setFieldKey(camelCase(e.target.value)); + } + }} + helperText="Set the user-facing name for this column." + /> +
+ +
+ setFieldKey(e.target.value)} + disabled={ + (type === FieldType.id && fieldKey === "id") || isAuditField + } + helperText="Set the Firestore field key to link to this column. It will display any existing data for this field key." + sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }} + /> +
+ +
+ +
+ + {isAuditField && tableSettings.audit === false && ( +
+ + This field requires auditing to be enabled on this table. + + + +
+ )} + + } + actions={{ + primary: { + onClick: () => { + addColumn({ + config: { + type, + name: columnLabel, + fieldName: fieldKey, + key: fieldKey, + config: {}, + }, + index: columnModal!.index, + }); + if (requireConfiguration) { + setColumnModal({ type: "config", columnKey: fieldKey }); + } else { + handleClose(); + } + logEvent(analytics, "create_column", { type }); + }, + disabled: + !columnLabel || + !fieldKey || + !type || + (isAuditField && tableSettings.audit === false), + children: requireConfiguration ? "Next" : "Add", + }, + secondary: { + onClick: handleClose, + children: "Cancel", + }, + }} + /> + ); +} diff --git a/src/components/ColumnModals/TypeChangeModal.tsx b/src/components/ColumnModals/TypeChangeModal.tsx new file mode 100644 index 00000000..e7609d6e --- /dev/null +++ b/src/components/ColumnModals/TypeChangeModal.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { useSetAtom } from "jotai"; +import { IColumnModalProps } from "."; + +import Modal from "@src/components/Modal"; +import FieldsDropdown from "./FieldsDropdown"; + +import { tableScope, updateColumnAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { analytics, logEvent } from "analytics"; + +export default function TypeChangeModal({ + handleClose, + column, +}: IColumnModalProps) { + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const [newType, setType] = useState(column.type); + + return ( + } + actions={{ + primary: { + onClick: () => { + const prevType = column.type; + updateColumn({ key: column.key, config: { type: newType } }); + handleClose(); + logEvent(analytics, "change_column_type", { newType, prevType }); + }, + children: "Update", + }, + }} + maxWidth="xs" + /> + ); +} diff --git a/src/components/ColumnModals/index.ts b/src/components/ColumnModals/index.ts new file mode 100644 index 00000000..14413e55 --- /dev/null +++ b/src/components/ColumnModals/index.ts @@ -0,0 +1,2 @@ +export * from "./ColumnModals"; +export { default } from "./ColumnModals"; diff --git a/src/components/Table/Column.tsx b/src/components/Table/Column.tsx new file mode 100644 index 00000000..c916eeb4 --- /dev/null +++ b/src/components/Table/Column.tsx @@ -0,0 +1,107 @@ +import { Grid, GridProps, Typography } from "@mui/material"; +import { alpha } from "@mui/material/styles"; + +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; + +export interface IColumnProps extends Partial { + label: string; + type?: FieldType; + secondaryItem?: React.ReactNode; + + active?: boolean; +} + +export default function Column({ + label, + type, + secondaryItem, + + active, + ...props +}: IColumnProps) { + return ( + `1px solid ${theme.palette.divider}`, + backgroundColor: "background.default", + + py: 0, + px: 1, + + color: "text.secondary", + "&:hover": { color: "text.primary" }, + + "& svg": { display: "block" }, + }, + active + ? { + backgroundColor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity + ), + color: (theme) => + theme.palette.mode === "dark" + ? theme.palette.text.primary + : theme.palette.primary.dark, + borderColor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.action.disabledOpacity + ), + + "&:hover": { + color: (theme) => + theme.palette.mode === "dark" + ? theme.palette.text.primary + : theme.palette.primary.dark, + }, + } + : {}, + ]} + > + {type && {getFieldProp("icon", type)}} + + + + {label} + + + + {secondaryItem && ( + + {secondaryItem} + + )} + + ); +} diff --git a/src/components/Table/ColumnHeader/ColumnHeader.tsx b/src/components/Table/ColumnHeader/ColumnHeader.tsx index 81bf2a4a..22d36d1d 100644 --- a/src/components/Table/ColumnHeader/ColumnHeader.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeader.tsx @@ -18,12 +18,17 @@ import LockIcon from "@mui/icons-material/LockOutlined"; import ColumnHeaderSort from "./ColumnHeaderSort"; -import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; +import { + globalScope, + userRolesAtom, + columnMenuAtom, +} from "@src/atoms/globalScope"; import { tableScope, updateColumnAtom } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; import { ColumnConfig } from "@src/types/table"; +import useKeyPress from "@src/hooks/useKeyPress"; const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( @@ -47,6 +52,8 @@ export default function DraggableHeaderRenderer({ }: IDraggableHeaderRendererProps) { const [userRoles] = useAtom(userRolesAtom, globalScope); const updateColumn = useSetAtom(updateColumnAtom, tableScope); + const openColumnMenu = useSetAtom(columnMenuAtom, globalScope); + const altPress = useKeyPress("Alt"); const [{ isDragging }, dragRef] = useDrag({ type: "COLUMN_DRAG", @@ -59,7 +66,6 @@ export default function DraggableHeaderRenderer({ const [{ isOver }, dropRef] = useDrop({ accept: "COLUMN_DRAG", drop: ({ key }: { key: string }) => { - console.log("drop", key, column.index); updateColumn({ key, config: {}, index: column.index }); }, collect: (monitor) => ({ @@ -72,11 +78,7 @@ export default function DraggableHeaderRenderer({ const handleOpenMenu = (e: React.MouseEvent) => { e.preventDefault(); - // FIXME: - // columnMenuRef?.current?.setSelectedColumnHeader({ - // column, - // anchorEl: buttonRef.current, - // }); + openColumnMenu({ column, anchorEl: buttonRef.current }); }; return ( @@ -186,7 +188,7 @@ export default function DraggableHeaderRenderer({ component="div" color="inherit" > - {column.name as string} + {altPress ? `${column.index}: ${column.fieldName}` : column.name} diff --git a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx index 150c40a3..87df1ac6 100644 --- a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx @@ -9,6 +9,10 @@ import { getFieldProp } from "@src/components/fields"; import { ColumnConfig } from "@src/types/table"; +import { colord, extend } from "colord"; +import mixPlugin from "colord/plugins/lch"; +extend([mixPlugin]); + const SORT_STATES = ["none", "desc", "asc"] as const; export interface IColumnHeaderSortProps { @@ -45,8 +49,19 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { onClick={handleSortClick} color="inherit" sx={{ + bgcolor: "background.default", + "&:hover": { + backgroundColor: (theme) => + colord(theme.palette.background.default) + .mix( + theme.palette.action.hover, + theme.palette.action.hoverOpacity + ) + .alpha(1) + .toHslString(), + }, + position: "relative", - backgroundColor: "background.default", opacity: currentSort !== "none" ? 1 : 0, ".column-header:hover &": { opacity: 1 }, @@ -59,7 +74,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { ), transform: currentSort === "asc" ? "rotate(180deg)" : "none", - "&:hover": { + "&:hover svg": { transform: currentSort === "asc" || nextSort === "asc" ? "rotate(180deg)" diff --git a/src/components/Table/FinalColumnHeader.tsx b/src/components/Table/FinalColumnHeader.tsx index 9b227cc0..0e59244d 100644 --- a/src/components/Table/FinalColumnHeader.tsx +++ b/src/components/Table/FinalColumnHeader.tsx @@ -1,30 +1,24 @@ -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { Column } from "react-data-grid"; import { Button } from "@mui/material"; import AddColumnIcon from "@src/assets/icons/AddColumn"; -import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; +import { + globalScope, + userRolesAtom, + columnModalAtom, +} from "@src/atoms/globalScope"; const FinalColumnHeader: Column["headerRenderer"] = ({ column }) => { const [userRoles] = useAtom(userRolesAtom, globalScope); - // FIXME: const { columnMenuRef } = useProjectContext(); - // if (!columnMenuRef) return null; + const openColumnModal = useSetAtom(columnModalAtom, globalScope); if (!userRoles.includes("ADMIN")) return null; - const handleClick = ( - event: React.MouseEvent - ) => { - // columnMenuRef?.current?.setSelectedColumnHeader({ - // column, - // anchorEl: event.currentTarget, - // }); - }; - return (