add ColumnMenu, independent ColumnModals

This commit is contained in:
Sidney Alcantara
2022-05-26 19:31:56 +10:00
parent e5176e78f3
commit 8a313279e8
34 changed files with 1578 additions and 144 deletions

View File

@@ -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<string[]>(
[]
);
/**
* 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",

View File

@@ -39,11 +39,18 @@ export const tableSchemaAtom = atom<TableSchema>({});
export const updateTableSchemaAtom = atom<
UpdateDocFunction<TableSchema> | 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<ColumnConfig[]>((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 = (

View File

@@ -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<string, any>;
handleClose: () => void;
handleSave: (
fieldName: string,
config: Record<string, any>,
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: <ArrowDownwardIcon />,
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: <ArrowUpwardIcon />,
onClick: () => {
setTableOrders(
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
handleClose();
},
active: isSorted && isAsc,
disabled: column.type === FieldType.id,
},
{
label: "Hide",
icon: <VisibilityIcon />,
onClick: () => {
if (updateUserSettings)
updateUserSettings({
tables: {
[formatSubTableName(tableId)]: {
hiddenFields: [...userDocHiddenFields, column.key],
},
},
});
handleClose();
},
disabled: !updateUserSettings,
},
{
label: "Filter…",
icon: <FilterIcon />,
// 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: <LockOpenIcon />,
activeIcon: <LockIcon />,
onClick: () => {
updateColumn({
key: column.key,
config: { editable: !column.editable },
});
handleClose();
},
active: !column.editable,
},
{
label: "Disable resize",
activeLabel: "Enable resize",
icon: <CellResizeIcon />,
onClick: () => {
updateColumn({
key: column.key,
config: { resizable: !column.resizable },
});
handleClose();
},
active: !column.resizable,
},
{
label: "Freeze",
activeLabel: "Unfreeze",
icon: <FreezeIcon />,
activeIcon: <UnfreezeIcon />,
onClick: () => {
updateColumn({ key: column.key, config: { fixed: !column.fixed } });
handleClose();
},
active: column.fixed,
},
{ type: "subheader", label: "Add column" },
{
label: "Add new to left…",
icon: <ColumnPlusBeforeIcon />,
onClick: () => {
openColumnModal({ type: "new", index: column.index - 1 });
handleClose();
},
},
{
label: "Add new to right…",
icon: <ColumnPlusAfterIcon />,
onClick: () => {
openColumnModal({ type: "new", index: column.index + 1 });
handleClose();
},
},
{ type: "subheader", label: "Configure" },
{
label: "Rename…",
icon: <EditIcon />,
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: <SettingsIcon />,
onClick: () => {
openColumnModal({ type: "config", columnKey: column.key });
handleClose();
},
disabled: !isConfigurable,
},
// {
// label: "Re-order",
// icon: <ReorderIcon />,
// onClick: () => alert("REORDER"),
// },
// {
// label: "Hide for everyone",
// activeLabel: "Show",
// icon: <VisibilityOffIcon />,
// activeIcon: <VisibilityIcon />,
// onClick: () => {
// actions.update(column.key, { hidden: !column.hidden });
// handleClose();
// },
// active: column.hidden,
// color: "error" as "error",
// },
{
label: `Delete column${altPress ? "" : "…"}`,
icon: <ColumnRemoveIcon />,
onClick: altPress
? handleDeleteColumn
: () =>
confirm({
title: "Delete column?",
body: (
<>
<Typography>
Only the column configuration will be deleted. No data will
be deleted. This cannot be undone.
</Typography>
<ColumnHeader type={column.type} label={column.name} />
<Typography sx={{ mt: 1 }}>
Key: <code style={{ userSelect: "all" }}>{column.key}</code>
</Typography>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDeleteColumn,
}),
color: "error" as "error",
},
];
return (
<Menu
id="column-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
MenuListProps={{ disablePadding: true }}
>
<ListItem>
<ListItemIcon style={{ minWidth: 36 }}>
{getFieldProp("icon", column.type)}
</ListItemIcon>
<ListItemText
primary={column.name as string}
secondary={
<>
Key: <code style={{ userSelect: "all" }}>{column.key}</code>
</>
}
primaryTypographyProps={{ variant: "subtitle2" }}
secondaryTypographyProps={{ variant: "caption" }}
sx={{ m: 0, minHeight: 40, "& > *": { userSelect: "none" } }}
/>
</ListItem>
<MenuContents menuItems={menuItems} />
</Menu>
);
}

View File

@@ -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 (
<Fragment key={index}>
<Divider variant="middle" sx={{ my: 0.5 }} />
{item.label && (
<ListSubheader disableSticky>{item.label}</ListSubheader>
)}
</Fragment>
);
let icon: JSX.Element = item.icon ?? <></>;
if (item.active && !!item.activeIcon) icon = item.activeIcon;
return (
<MenuItem
key={index}
onClick={item.onClick}
color={item.color}
selected={item.active}
disabled={item.disabled}
>
<ListItemIcon>{icon}</ListItemIcon>
{item.active ? item.activeLabel : item.label}
</MenuItem>
);
})}
</>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./ColumnMenu";
export { default } from "./ColumnMenu";

View File

@@ -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 (
<Modal
maxWidth="md"
onClose={handleClose}
title={`${column.name}: Settings`}
disableBackdropClick
disableEscapeKeyDown
children={
<Suspense fallback={<Loading fullScreen={false} />}>
<>
{initializable && (
<>
<section style={{ marginTop: 1 }}>
{/* top margin fixes visual bug */}
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<DefaultValueInput
handleChange={handleChange}
column={{ ...column, config: newConfig }}
/>
</ErrorBoundary>
</section>
</>
)}
{customFieldSettings && (
<Stack
spacing={3}
sx={{ borderTop: 1, borderColor: "divider", pt: 3 }}
>
{createElement(customFieldSettings, {
config: newConfig,
onChange: handleChange,
fieldName: column.fieldName,
onBlur: validateSettings,
errors,
})}
</Stack>
)}
{rendedFieldSettings && (
<Stack
spacing={3}
sx={{ borderTop: 1, borderColor: "divider", pt: 3 }}
>
<Typography variant="subtitle1">
Rendered field config
</Typography>
{createElement(rendedFieldSettings, {
config: newConfig,
onChange: handleChange,
onBlur: validateSettings,
errors,
})}
</Stack>
)}
{/* {
<ConfigForm
type={type}
config={newConfig}
/>
} */}
</>
</Suspense>
}
actions={{
primary: {
onClick: async () => {
const errors = validateSettings();
if (Object.keys(errors).length > 0) {
confirm({
title: "Invalid settings",
body: (
<>
<Typography>Please fix the following settings:</Typography>
<ul style={{ paddingLeft: "1.5em" }}>
{Object.entries(errors).map(([key, message]) => (
<li key={key}>
<>
<code>{key}</code>: {message}
</>
</li>
))}
</ul>
</>
),
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 tables 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",
},
}}
/>
);
}

View File

@@ -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 (
<CodeEditorComponent
value={dynamicValueFn}
diagnosticsOptions={functionBodyOnly ? undefined : diagnosticsOptions}
extraLibs={[
defaultValueDefs.replace(
`"PLACEHOLDER_OUTPUT_TYPE"`,
`${returnType} | Promise<${returnType}>`
),
]}
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 (
<>
<TextField
select
label="Default value type"
value={column.config?.defaultValue?.type ?? "undefined"}
onChange={(e) => handleChange("defaultValue.type")(e.target.value)}
fullWidth
sx={{ mb: 1 }}
>
<MenuItem value="undefined">
<ListItemText
primary="Undefined"
secondary="No default value. The field will not appear in the rows corresponding Firestore document by default."
/>
</MenuItem>
<MenuItem value="null">
<ListItemText
primary="Null"
secondary={
<>
Initialise as <code>null</code>.
</>
}
/>
</MenuItem>
<MenuItem value="static">
<ListItemText
primary="Static"
secondary="Set a specific default value for all cells in this column."
/>
</MenuItem>
<MenuItem
value="dynamic"
disabled={!projectSettings.rowyRunUrl}
sx={{
"&.Mui-disabled": { opacity: 1, color: "text.disabled" },
"&.Mui-disabled .MuiListItemText-secondary": {
color: "text.disabled",
},
}}
>
<ListItemText
primary={
projectSettings.rowyRunUrl ? (
"Dynamic"
) : (
<>
Dynamic {" "}
<Typography color="error" variant="inherit" component="span">
Requires Rowy Run setup
</Typography>
</>
)
}
secondary="Write code to set the default value using Rowy Run"
/>
</MenuItem>
</TextField>
{(!column.config?.defaultValue ||
column.config?.defaultValue.type === "undefined") && (
<>
<FormControlLabel
value="required"
label={
<>
Make this column required
<Typography
variant="caption"
color="text.secondary"
display="block"
>
The row will not be created or updated unless all required
values are set.
</Typography>
</>
}
control={
<Checkbox
checked={column.config?.required}
onChange={(e) => handleChange("required")(e.target.checked)}
name="required"
/>
}
/>
</>
)}
{column.config?.defaultValue?.type === "static" && customFieldInput && (
<form>
<FormAutosave
control={control}
handleSave={(values) =>
handleChange("defaultValue.value")(values[column.fieldName])
}
/>
{createElement(customFieldInput, {
column,
control,
docRef: {},
disabled: false,
})}
</form>
)}
{column.config?.defaultValue?.type === "dynamic" && (
<>
<CodeEditorHelper docLink={WIKI_LINKS.howToDefaultValues} />
<Suspense fallback={<FieldSkeleton height={100} />}>
<CodeEditor
column={column}
type={column.type}
handleChange={handleChange}
/>
</Suspense>
</>
)}
</>
);
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export * from "./ColumnConfig";
export { default } from "./ColumnConfig";

View File

@@ -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 <NewColumnModal handleClose={handleClose} />;
const column = tableSchema.columns?.[columnModal.columnKey ?? ""];
if (!column) return null;
if (columnModal.type === "name")
return <NameChangeModal handleClose={handleClose} column={column} />;
if (columnModal.type === "type")
return <TypeChangeModal handleClose={handleClose} column={column} />;
if (columnModal.type === "config")
return <ColumnConfigModal handleClose={handleClose} column={column} />;
return null;
}

View File

@@ -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 (
<MultiSelect
multiple={false}
{...props}
value={value ? value : ""}
onChange={onChange}
options={options}
{...({
AutocompleteProps: {
groupBy: (option: typeof options[number]) =>
getFieldProp("group", option.value),
},
} as any)}
itemRenderer={(option) => (
<>
<ListItemIcon style={{ minWidth: 40 }}>
{getFieldProp("icon", option.value as FieldType)}
</ListItemIcon>
{option.label}
</>
)}
label={label || "Field type"}
labelPlural="field types"
TextFieldProps={{
hiddenLabel: hideLabel,
helperText: value && getFieldProp("description", value),
...props.TextFieldProps,
SelectProps: {
displayEmpty: true,
renderValue: () => (
<>
<ListItemIcon
sx={{
minWidth: 40,
verticalAlign: "text-bottom",
"& svg": { my: -0.5 },
}}
>
{getFieldProp("icon", value as FieldType)}
</ListItemIcon>
{getFieldProp("name", value as FieldType)}
</>
),
...props.TextFieldProps?.SelectProps,
},
}}
/>
);
}

View File

@@ -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 (
<Modal
onClose={handleClose}
title="Rename column"
maxWidth="xs"
children={
<TextField
value={newName}
autoFocus
variant="filled"
id="name"
label="Column name"
type="text"
fullWidth
onChange={(e) => setName(e.target.value)}
/>
}
actions={{
primary: {
onClick: () => {
updateColumn({ key: column.key, config: { name: newName } });
handleClose();
},
children: "Update",
},
secondary: {
onClick: handleClose,
children: "Cancel",
},
}}
/>
);
}

View File

@@ -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<IColumnModalProps, "handleClose">) {
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 (
<Modal
onClose={handleClose}
title="Add new column"
fullWidth
maxWidth="xs"
children={
<>
<section>
<TextField
value={columnLabel}
autoFocus
variant="filled"
id="columnName"
label="Column name"
type="text"
fullWidth
onChange={(e) => {
setColumnLabel(e.target.value);
if (type !== FieldType.id && !isAuditField) {
setFieldKey(camelCase(e.target.value));
}
}}
helperText="Set the user-facing name for this column."
/>
</section>
<section>
<TextField
value={fieldKey}
variant="filled"
id="fieldKey"
label="Field key"
type="text"
fullWidth
onChange={(e) => 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" } }}
/>
</section>
<section>
<FieldsDropdown value={type} onChange={handleTypeChange} />
</section>
{isAuditField && tableSettings.audit === false && (
<section>
<Typography gutterBottom>
This field requires auditing to be enabled on this table.
</Typography>
<Button
variant="contained"
color="primary"
onClick={() => {
if (updateTable)
updateTable({
id: tableSettings.id,
tableType: tableSettings.tableType,
audit: true,
});
}}
>
Enable auditing on this table
</Button>
</section>
)}
</>
}
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",
},
}}
/>
);
}

View File

@@ -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<FieldType>(column.type);
return (
<Modal
onClose={handleClose}
title="Change column type"
children={<FieldsDropdown value={newType} onChange={setType} />}
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"
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./ColumnModals";
export { default } from "./ColumnModals";

View File

@@ -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<GridProps> {
label: string;
type?: FieldType;
secondaryItem?: React.ReactNode;
active?: boolean;
}
export default function Column({
label,
type,
secondaryItem,
active,
...props
}: IColumnProps) {
return (
<Grid
container
alignItems="center"
wrap="nowrap"
{...props}
sx={[
{
width: "100%",
height: 42,
border: (theme) => `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 && <Grid item>{getFieldProp("icon", type)}</Grid>}
<Grid
item
xs
style={{
flexShrink: 1,
overflow: "hidden",
}}
>
<Typography
component={Grid}
item
variant="caption"
noWrap
sx={{
fontWeight: "fontWeightMedium",
lineHeight: "42px",
display: "block",
userSelect: "none",
ml: 0.5,
}}
>
{label}
</Typography>
</Grid>
{secondaryItem && (
<Grid item sx={{ ml: 1 }}>
{secondaryItem}
</Grid>
)}
</Grid>
);
}

View File

@@ -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) => (
<Tooltip {...props} classes={{ popper: className }} />
@@ -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}
</Typography>
</LightTooltip>
</Grid>

View File

@@ -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)"

View File

@@ -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<any>["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<HTMLButtonElement, MouseEvent>
) => {
// columnMenuRef?.current?.setSelectedColumnHeader({
// column,
// anchorEl: event.currentTarget,
// });
};
return (
<Button
onClick={handleClick}
onClick={(e) => openColumnModal({ type: "new" })}
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}

View File

@@ -13,9 +13,7 @@ import DataGrid, {
} from "react-data-grid";
import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer";
import TableToolbar from "@src/components/TableToolbar/TableToolbar";
import ColumnHeader from "./ColumnHeader";
// import ColumnMenu from "./ColumnMenu";
// import ContextMenu from "./ContextMenu";
import FinalColumnHeader from "./FinalColumnHeader";
import FinalColumn from "./formatters/FinalColumn";
@@ -138,21 +136,22 @@ export default function Table() {
// Handle columns with field names that use dot notation (nested fields)
const rows =
useMemo(() => {
const columnsWithNestedFieldNames = columns
.map((col) => col.fieldName)
.filter((fieldName) => fieldName.includes("."));
// const columnsWithNestedFieldNames = columns
// .map((col) => col.fieldName)
// .filter((fieldName) => fieldName.includes("."));
if (columnsWithNestedFieldNames.length === 0) return tableRows;
// if (columnsWithNestedFieldNames.length === 0)
return tableRows;
return tableRows.map((row) =>
columnsWithNestedFieldNames.reduce(
(acc, fieldName) => ({
...acc,
[fieldName]: get(row, fieldName),
}),
{ ...row }
)
);
// return tableRows.map((row) =>
// columnsWithNestedFieldNames.reduce(
// (acc, fieldName) => ({
// ...acc,
// [fieldName]: get(row, fieldName),
// }),
// { ...row }
// )
// );
}, [columns, tableRows]) ?? [];
const rowsContainerRef = useRef<HTMLDivElement>(null);
@@ -189,8 +188,6 @@ export default function Table() {
<Hotkeys selectedCell={selectedCell} />
</Suspense> */}
<TableContainer ref={rowsContainerRef} rowHeight={rowHeight}>
<TableToolbar />
<DndProvider backend={HTML5Backend}>
<DataGrid
onColumnResize={handleResize}
@@ -276,7 +273,6 @@ export default function Table() {
</DndProvider>
</TableContainer>
{/* <ColumnMenu /> */}
{/* <ContextMenu />
<BulkActions
selectedRows={selectedRows}

View File

@@ -1,5 +1,6 @@
import { styled, alpha, darken, lighten } from "@mui/material";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
// import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer";
import { colord, extend } from "colord";
@@ -13,7 +14,7 @@ export const TableContainer = styled("div", {
})<{ rowHeight: number }>(({ theme, rowHeight }) => ({
display: "flex",
flexDirection: "column",
height: `calc(100vh - ${APP_BAR_HEIGHT}px)`,
height: `calc(100vh - ${APP_BAR_HEIGHT}px - ${TABLE_TOOLBAR_HEIGHT}px)`,
"& > .rdg": {
// FIXME:

View File

@@ -72,7 +72,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.ref.path}
{row._rowy_ref.path}
</code>
</>
),
@@ -94,6 +94,7 @@ export default function FinalColumn({ row }: FormatterProps<TableRow, any>) {
),
},
}}
disabled={!row._rowy_ref.path}
>
<DeleteIcon />
</IconButton>

View File

@@ -31,13 +31,10 @@ import { TableSettings } from "@src/types/table";
import { analytics, logEvent } from "@src/analytics";
import { runRoutes } from "@src/constants/runRoutes";
import {
CONFIG,
TABLE_GROUP_SCHEMAS,
TABLE_SCHEMAS,
} from "@src/config/dbPaths";
import { CONFIG } from "@src/config/dbPaths";
import { ROUTES } from "@src/constants/routes";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { getSchemaPath } from "@src/utils/table";
const customComponents = {
tableName: {
@@ -122,11 +119,7 @@ export default function TableSettingsDialog() {
cancel: "Later",
handleConfirm: async () => {
const tablePath = data.collection;
const tableConfigPath = `${
data.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${data.id}`;
const tableConfigPath = getSchemaPath(data);
if (hasExtensions) {
// find derivative, default value

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useMemo, useState } from "react";
import { useAtom } from "jotai";
import { isEqual } from "lodash-es";
@@ -7,8 +7,7 @@ import VisibilityOffIcon from "@mui/icons-material/VisibilityOffOutlined";
import MultiSelect from "@rowy/multiselect";
import ButtonWithStatus from "@src/components/ButtonWithStatus";
// FIXME:
// import Column from "@src/components/Wizards/Column";
import Column from "@src/components/Table/Column";
import {
globalScope,
@@ -18,6 +17,7 @@ import {
import {
tableScope,
tableIdAtom,
tableSchemaAtom,
tableColumnsOrderedAtom,
} from "@src/atoms/tableScope";
import { formatSubTableName } from "@src/utils/table";
@@ -27,16 +27,24 @@ export default function HiddenFields() {
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [open, setOpen] = useState(false);
// Store local selection here
// Initialise hiddenFields from user doc
const userDocHiddenFields =
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
const userDocHiddenFields = useMemo(
() =>
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [],
[userSettings.tables, tableId]
);
const [hiddenFields, setHiddenFields] =
useState<string[]>(userDocHiddenFields);
useEffect(() => {
setHiddenFields(userDocHiddenFields);
}, [userDocHiddenFields]);
const tableColumns = tableColumnsOrdered.map(({ key, name }) => ({
value: key,
@@ -61,12 +69,12 @@ export default function HiddenFields() {
any
>["renderOption"] = (props, option, { selected }) => (
<li {...props}>
{/* FIXME: <Column
<Column
label={option.label}
type={tableState.columns[option.value]?.type}
type={tableSchema.columns?.[option.value]?.type}
secondaryItem={<VisibilityOffIcon className="hiddenIcon" />}
active={selected}
/> */}
/>
</li>
);
@@ -89,49 +97,46 @@ export default function HiddenFields() {
anchorEl: buttonRef.current,
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
},
},
}}
{...({
AutocompleteProps: {
renderOption,
sx: {
"& .MuiAutocomplete-option": {
padding: 0,
paddingLeft: "0 !important",
borderRadius: 0,
marginBottom: "-1px",
"&::after": { content: "none" },
sx: {
"& .MuiAutocomplete-listbox .MuiAutocomplete-option": {
padding: 0,
paddingLeft: "0 !important",
borderRadius: 0,
marginBottom: "-1px",
"&:hover, &.Mui-focused, &.Mui-focusVisible": {
backgroundColor: "transparent",
"&::after": { content: "none" },
position: "relative",
zIndex: 2,
"& > div": {
color: "text.primary",
borderColor: "currentColor",
boxShadow: (theme: any) =>
`0 0 0 1px ${theme.palette.text.primary} inset`,
},
"& .hiddenIcon": { opacity: 0.5 },
},
'&[aria-selected="true"], &[aria-selected="true"].Mui-focused, &[aria-selected="true"].Mui-focusVisible':
{
"&:hover, &.Mui-focused, &.Mui-focusVisible": {
backgroundColor: "transparent",
position: "relative",
zIndex: 1,
zIndex: 2,
"& .hiddenIcon": { opacity: 1 },
"& > div": {
color: "text.primary",
borderColor: "currentColor",
boxShadow: (theme: any) =>
`0 0 0 1px ${theme.palette.text.primary} inset`,
},
"& .hiddenIcon": { opacity: 0.5 },
},
'&[aria-selected="true"], &[aria-selected="true"].Mui-focused, &[aria-selected="true"].Mui-focusVisible':
{
backgroundColor: "transparent",
position: "relative",
zIndex: 1,
"& .hiddenIcon": { opacity: 1 },
},
},
},
},
},
} as any)}
}}
{...{ AutocompleteProps: { renderOption } }}
label="Hidden fields"
labelPlural="fields"
options={tableColumns}

View File

@@ -24,7 +24,7 @@ import {
import { FieldType } from "@src/constants/fields";
// import { useSnackLogContext } from "@src/contexts/SnackLogContext";
export const TABLE_HEADER_HEIGHT = 44;
export const TABLE_TOOLBAR_HEIGHT = 44;
export default function TableToolbar() {
const [userRoles] = useAtom(userRolesAtom, globalScope);
@@ -49,7 +49,7 @@ export default function TableToolbar() {
sx={{
pl: (theme) => `max(env(safe-area-inset-left), ${theme.spacing(2)})`,
pb: 1.5,
height: TABLE_HEADER_HEIGHT,
height: TABLE_TOOLBAR_HEIGHT,
overflowX: "auto",
overflowY: "hidden",
"& > *": { flexShrink: 0 },

View File

@@ -1,9 +1,7 @@
import { Fade, Stack, Button, Skeleton, SkeletonProps } from "@mui/material";
import AddRowIcon from "@src/assets/icons/AddRow";
// FIXME:
// import { TABLE_HEADER_HEIGHT } from "@src/components/TableToolbar";
const TABLE_HEADER_HEIGHT = 44;
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
const ButtonSkeleton = (props: Partial<SkeletonProps>) => (
<Skeleton
@@ -26,7 +24,7 @@ export default function TableToolbarSkeleton() {
pl: 2,
pr: 2,
pb: 1.5,
height: TABLE_HEADER_HEIGHT,
height: TABLE_TOOLBAR_HEIGHT,
}}
>
<ButtonSkeleton>

View File

@@ -19,7 +19,7 @@ import {
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { useActionParams } from "./FormDialog/Context";
import { runRoutes } from "@src/constants/runRoutes";
import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "@src/config/dbPaths";
import { getSchemaPath } from "@src/utils/table";
const replacer = (data: any) => (m: string, key: string) => {
const objKey = key.split(":")[0];
@@ -80,11 +80,7 @@ export default function ActionFab({
ref: { path: ref.path },
column: { ...column, editor: undefined },
action,
schemaDocPath: `${
tableSettings.tableType === "collectionGroup"
? TABLE_GROUP_SCHEMAS
: TABLE_SCHEMAS
}/${tableSettings.id}`,
schemaDocPath: getSchemaPath(tableSettings),
actionParams,
});

View File

@@ -27,7 +27,7 @@ import { getLabel } from "@src/components/fields/Connector/utils";
import { useSnackbar } from "notistack";
import { globalScope, rowyRunAtom } from "@src/atoms/globalScope";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "@src/config/dbPaths";
import { getSchemaPath } from "@src/utils/table";
export interface IPopupContentsProps
extends Omit<IConnectorSelectProps, "className" | "TextFieldProps"> {}
@@ -74,11 +74,7 @@ export default function PopupContents({
body: {
columnKey: column.key,
query: query,
schemaDocPath: `${
tableSettings.tableType === "collectionGroup"
? TABLE_GROUP_SCHEMAS
: TABLE_SCHEMAS
}/${tableSettings.id}`,
schemaDocPath: getSchemaPath(tableSettings),
rowDocPath: docRef.path,
},
});

View File

@@ -5,7 +5,7 @@ import { useAtom, useSetAtom } from "jotai";
import { Grid, InputLabel, FormHelperText } from "@mui/material";
import MultiSelect from "@rowy/multiselect";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
// FIXME: import FieldsDropdown from "@src/components/Table/ColumnMenu/FieldsDropdown";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import {
@@ -108,7 +108,7 @@ export default function Settings({
</Grid>
<Grid item xs={12} md={6}>
{/* <FieldsDropdown
<FieldsDropdown
label="Output field type"
value={config.renderFieldType}
options={Object.values(FieldType).filter(
@@ -129,7 +129,7 @@ export default function Settings({
helperText: errors.renderFieldType,
onBlur,
}}
/> */}
/>
</Grid>
</Grid>

View File

@@ -8,25 +8,24 @@ import { Fade } from "@mui/material";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import HeaderRowSkeleton from "@src/components/Table/HeaderRowSkeleton";
import EmptyTable from "@src/components/Table/EmptyTable";
import TableToolbar from "@src/components/TableToolbar";
import Table from "@src/components/Table";
import ColumnMenu from "@src/components/ColumnMenu";
import ColumnModals from "@src/components/ColumnModals";
import { currentUserAtom, globalScope } from "@src/atoms/globalScope";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
// tableSettingsAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
import ActionParamsProvider from "@src/components/fields/Action/FormDialog/Provider";
function TablePage() {
const [tableId] = useAtom(tableIdAtom, tableScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
console.log(tableSchema);
if (isEmpty(tableSchema.columns))
return (
<Fade style={{ transitionDelay: "500ms" }}>
@@ -37,11 +36,20 @@ function TablePage() {
);
return (
// <Suspense fallback={<div>Loading rows…</div>}>
<ActionParamsProvider>
<Table />
<Suspense fallback={<TableToolbarSkeleton />}>
<TableToolbar />
</Suspense>
<Suspense fallback={<HeaderRowSkeleton />}>
<Table />
</Suspense>
<Suspense>
<ColumnMenu />
<ColumnModals />
</Suspense>
</ActionParamsProvider>
// </Suspense>
);
}

View File

@@ -85,9 +85,13 @@ export type ColumnConfig = {
/** Set column width for all users */
width?: number;
/** If false (not undefined), locks the column for all users */
editable?: boolean;
editable?: boolean = true;
/** Hide the column for all users */
hidden?: boolean;
hidden?: boolean = false;
/** Freeze the column to the left */
fixed?: boolean = false;
/** Prevent column resizability */
resizable?: boolean = true;
config?: {
/** Set column to required */

View File

@@ -1,6 +1,7 @@
import { mergeWith, isArray, get } from "lodash-es";
import type { User } from "firebase/auth";
import { TABLE_GROUP_SCHEMAS, TABLE_SCHEMAS } from "@src/config/dbPaths";
import { TableSettings } from "@src/types/table";
/**
* Creates a standard user object to write to table rows
@@ -98,18 +99,26 @@ export const decrementId = (id: string = "zzzzzzzzzzzzzzzzzzzz") => {
// Gets sub-table ID in $1
const formatPathRegex = /\/[^\/]+\/([^\/]+)/g;
/** Format table path */
export const formatPath = (
tablePath: string,
isCollectionGroup: boolean = false
) => {
return `${
isCollectionGroup ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS
}/${tablePath.replace(formatPathRegex, "/subTables/$1")}`;
};
/**
* Gets the path to the tables schema doc, accounting for sub-tables
* and collectionGroup tables
* @param id - The table ID (could include sub-table ID)
* @param tableType - primaryCollection (default) or collectionGroup
* @returns Path to the tables schema doc
*/
export const getSchemaPath = (
tableSettings: Pick<TableSettings, "id" | "tableType">
) =>
(tableSettings.tableType === "collectionGroup"
? TABLE_GROUP_SCHEMAS
: TABLE_SCHEMAS) +
"/" +
tableSettings.id.replace(formatPathRegex, "/subTables/$1");
/** Format sub-table name to store settings in user settings */
export const formatSubTableName = (tablePath: string) =>
tablePath
? tablePath.replace(formatPathRegex, "/subTables/$1").replace(/\//g, "_")
: "";
/**
* Format sub-table name to store settings in user settings
* @param id - Sub-table ID, including parent table ID
* @returns Standardized sub-table name
*/
export const formatSubTableName = (id?: string) =>
id ? id.replace(formatPathRegex, "/subTables/$1").replace(/\//g, "_") : "";