mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 18:14:58 +08:00
WIP
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
|||||||
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
|
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
|
||||||
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
||||||
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
|
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
|
||||||
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
|
||||||
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
|
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
|
||||||
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
||||||
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
||||||
@@ -45,7 +44,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
|||||||
const updateRowMutation = useUpdateRowMutation();
|
const updateRowMutation = useUpdateRowMutation();
|
||||||
const createRowMutation = useCreateRowMutation();
|
const createRowMutation = useCreateRowMutation();
|
||||||
const reorderRowMutation = useReorderRowMutation();
|
const reorderRowMutation = useReorderRowMutation();
|
||||||
const createPropertyMutation = useCreatePropertyMutation();
|
|
||||||
const createViewMutation = useCreateViewMutation();
|
const createViewMutation = useCreateViewMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,14 +74,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
|||||||
createRowMutation.mutate({ baseId });
|
createRowMutation.mutate({ baseId });
|
||||||
}, [baseId, createRowMutation]);
|
}, [baseId, createRowMutation]);
|
||||||
|
|
||||||
const handleAddColumn = useCallback(() => {
|
|
||||||
createPropertyMutation.mutate({
|
|
||||||
baseId,
|
|
||||||
name: t("New property"),
|
|
||||||
type: "text",
|
|
||||||
});
|
|
||||||
}, [baseId, createPropertyMutation, t]);
|
|
||||||
|
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
(viewId: string) => {
|
(viewId: string) => {
|
||||||
setActiveViewId(viewId);
|
setActiveViewId(viewId);
|
||||||
@@ -188,7 +178,7 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
|||||||
table={table}
|
table={table}
|
||||||
onCellUpdate={handleCellUpdate}
|
onCellUpdate={handleCellUpdate}
|
||||||
onAddRow={handleAddRow}
|
onAddRow={handleAddRow}
|
||||||
onAddColumn={handleAddColumn}
|
baseId={baseId}
|
||||||
onColumnReorder={handleColumnReorder}
|
onColumnReorder={handleColumnReorder}
|
||||||
onResizeEnd={handleResizeEnd}
|
onResizeEnd={handleResizeEnd}
|
||||||
onRowReorder={handleRowReorder}
|
onRowReorder={handleRowReorder}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Popover } from "@mantine/core";
|
import { Popover } from "@mantine/core";
|
||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
import {
|
||||||
|
IBaseProperty,
|
||||||
|
PersonTypeOptions,
|
||||||
|
} from "@/features/base/types/base.types";
|
||||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||||
@@ -17,10 +20,14 @@ type CellPersonProps = {
|
|||||||
|
|
||||||
export function CellPerson({
|
export function CellPerson({
|
||||||
value,
|
value,
|
||||||
|
property,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: CellPersonProps) {
|
}: CellPersonProps) {
|
||||||
|
const allowMultiple =
|
||||||
|
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
|
||||||
|
|
||||||
const personIds = Array.isArray(value)
|
const personIds = Array.isArray(value)
|
||||||
? (value as string[])
|
? (value as string[])
|
||||||
: typeof value === "string"
|
: typeof value === "string"
|
||||||
@@ -53,31 +60,38 @@ export function CellPerson({
|
|||||||
)
|
)
|
||||||
: members;
|
: members;
|
||||||
|
|
||||||
const handleAdd = useCallback(
|
const handleSelect = useCallback(
|
||||||
(memberId: string) => {
|
(memberId: string) => {
|
||||||
if (personIds.includes(memberId)) return;
|
if (allowMultiple) {
|
||||||
onCommit([...personIds, memberId]);
|
// Multi mode: toggle add/remove
|
||||||
|
if (personIds.includes(memberId)) {
|
||||||
|
const newIds = personIds.filter((id) => id !== memberId);
|
||||||
|
onCommit(newIds.length > 0 ? newIds : null);
|
||||||
|
} else {
|
||||||
|
onCommit([...personIds, memberId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single mode: replace or clear
|
||||||
|
if (personIds.includes(memberId)) {
|
||||||
|
onCommit(null);
|
||||||
|
} else {
|
||||||
|
onCommit(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[personIds, onCommit],
|
[allowMultiple, personIds, onCommit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemove = useCallback(
|
const handleRemove = useCallback(
|
||||||
(memberId: string) => {
|
(memberId: string) => {
|
||||||
const newIds = personIds.filter((id) => id !== memberId);
|
if (allowMultiple) {
|
||||||
onCommit(newIds.length > 0 ? newIds : null);
|
const newIds = personIds.filter((id) => id !== memberId);
|
||||||
},
|
onCommit(newIds.length > 0 ? newIds : null);
|
||||||
[personIds, onCommit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(memberId: string) => {
|
|
||||||
if (personIds.includes(memberId)) {
|
|
||||||
handleRemove(memberId);
|
|
||||||
} else {
|
} else {
|
||||||
handleAdd(memberId);
|
onCommit(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[personIds, handleAdd, handleRemove],
|
[allowMultiple, personIds, onCommit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -150,9 +164,11 @@ export function CellPerson({
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
<div className={cellClasses.personDropdownDivider} />
|
<div className={cellClasses.personDropdownDivider} />
|
||||||
<div className={cellClasses.personDropdownHint}>
|
{allowMultiple && (
|
||||||
Select as many as you like
|
<div className={cellClasses.personDropdownHint}>
|
||||||
</div>
|
Select as many as you like
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={cellClasses.selectDropdown}>
|
<div className={cellClasses.selectDropdown}>
|
||||||
{filteredMembers.map((member) => (
|
{filteredMembers.map((member) => (
|
||||||
<div
|
<div
|
||||||
@@ -160,7 +176,7 @@ export function CellPerson({
|
|||||||
className={`${cellClasses.selectOption} ${
|
className={`${cellClasses.selectOption} ${
|
||||||
selectedSet.has(member.id) ? cellClasses.selectOptionActive : ""
|
selectedSet.has(member.id) ? cellClasses.selectOptionActive : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleToggle(member.id)}
|
onClick={() => handleSelect(member.id)}
|
||||||
>
|
>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={member.avatarUrl}
|
avatarUrl={member.avatarUrl}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type GridContainerProps = {
|
|||||||
table: Table<IBaseRow>;
|
table: Table<IBaseRow>;
|
||||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||||
onAddRow?: () => void;
|
onAddRow?: () => void;
|
||||||
onAddColumn?: () => void;
|
baseId?: string;
|
||||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||||
onResizeEnd?: () => void;
|
onResizeEnd?: () => void;
|
||||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||||
@@ -46,7 +46,7 @@ export function GridContainer({
|
|||||||
table,
|
table,
|
||||||
onCellUpdate,
|
onCellUpdate,
|
||||||
onAddRow,
|
onAddRow,
|
||||||
onAddColumn,
|
baseId,
|
||||||
onColumnReorder,
|
onColumnReorder,
|
||||||
onResizeEnd,
|
onResizeEnd,
|
||||||
onRowReorder,
|
onRowReorder,
|
||||||
@@ -115,8 +115,8 @@ export function GridContainer({
|
|||||||
const gridTemplateColumns = useMemo(() => {
|
const gridTemplateColumns = useMemo(() => {
|
||||||
const visibleColumns = table.getVisibleLeafColumns();
|
const visibleColumns = table.getVisibleLeafColumns();
|
||||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||||
return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
|
return columnWidths.join(" ") + (baseId ? " 40px" : "");
|
||||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
|
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
|
||||||
|
|
||||||
const totalHeight = virtualizer.getTotalSize();
|
const totalHeight = virtualizer.getTotalSize();
|
||||||
|
|
||||||
@@ -148,6 +148,18 @@ export function GridContainer({
|
|||||||
onAddRow?.();
|
onAddRow?.();
|
||||||
}, [onAddRow]);
|
}, [onAddRow]);
|
||||||
|
|
||||||
|
const handlePropertyCreated = useCallback(() => {
|
||||||
|
// Wait for React to re-render with the new column, then scroll to it
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
left: scrollRef.current.scrollWidth,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
@@ -194,7 +206,12 @@ export function GridContainer({
|
|||||||
items={sortableColumnIds}
|
items={sortableColumnIds}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<GridHeader table={table} onAddColumn={onAddColumn} />
|
<GridHeader
|
||||||
|
table={table}
|
||||||
|
baseId={baseId}
|
||||||
|
columnOrder={table.getState().columnOrder}
|
||||||
|
onPropertyCreated={handlePropertyCreated}
|
||||||
|
/>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
{paddingTop > 0 && (
|
{paddingTop > 0 && (
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
import { memo, useCallback } from "react";
|
import { memo } from "react";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table, ColumnOrderState } from "@tanstack/react-table";
|
||||||
import { IBaseRow } from "@/features/base/types/base.types";
|
import { IBaseRow } from "@/features/base/types/base.types";
|
||||||
import { GridHeaderCell } from "./grid-header-cell";
|
import { GridHeaderCell } from "./grid-header-cell";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
|
||||||
import classes from "@/features/base/styles/grid.module.css";
|
import classes from "@/features/base/styles/grid.module.css";
|
||||||
|
|
||||||
type GridHeaderProps = {
|
type GridHeaderProps = {
|
||||||
table: Table<IBaseRow>;
|
table: Table<IBaseRow>;
|
||||||
onAddColumn?: () => void;
|
baseId?: string;
|
||||||
|
// Passed explicitly to break memo when columns change
|
||||||
|
// (table ref is stable from useReactTable, so memo won't fire without this)
|
||||||
|
columnOrder: ColumnOrderState;
|
||||||
|
onPropertyCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GridHeader = memo(function GridHeader({
|
export const GridHeader = memo(function GridHeader({
|
||||||
table,
|
table,
|
||||||
onAddColumn,
|
baseId,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
columnOrder: _columnOrder,
|
||||||
|
onPropertyCreated,
|
||||||
}: GridHeaderProps) {
|
}: GridHeaderProps) {
|
||||||
const headerGroups = table.getHeaderGroups();
|
const headerGroups = table.getHeaderGroups();
|
||||||
|
|
||||||
const handleAddColumn = useCallback(() => {
|
|
||||||
onAddColumn?.();
|
|
||||||
}, [onAddColumn]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.headerRow} role="row">
|
<div className={classes.headerRow} role="row">
|
||||||
{headerGroups[0]?.headers.map((header) => (
|
{headerGroups[0]?.headers.map((header) => (
|
||||||
<GridHeaderCell key={header.id} header={header} />
|
<GridHeaderCell key={header.id} header={header} />
|
||||||
))}
|
))}
|
||||||
{onAddColumn && (
|
{baseId && (
|
||||||
<div
|
<CreatePropertyPopover
|
||||||
className={classes.addColumnButton}
|
baseId={baseId}
|
||||||
onClick={handleAddColumn}
|
onPropertyCreated={onPropertyCreated}
|
||||||
role="button"
|
/>
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<IconPlus size={16} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type ChoiceEditorProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
showCategories?: boolean;
|
showCategories?: boolean;
|
||||||
|
hideButtons?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChoiceEditor({
|
export function ChoiceEditor({
|
||||||
@@ -62,14 +63,28 @@ export function ChoiceEditor({
|
|||||||
onClose,
|
onClose,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
showCategories = false,
|
showCategories = false,
|
||||||
|
hideButtons = false,
|
||||||
}: ChoiceEditorProps) {
|
}: ChoiceEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
||||||
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync from parent only when not in live mode (hideButtons = create flow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(initialChoices);
|
if (!hideButtons) {
|
||||||
}, [initialChoices]);
|
setDraft(initialChoices);
|
||||||
|
}
|
||||||
|
}, [initialChoices, hideButtons]);
|
||||||
|
|
||||||
|
// In live mode, propagate draft changes to parent immediately
|
||||||
|
const onSaveRef = useRef(onSave);
|
||||||
|
onSaveRef.current = onSave;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hideButtons) {
|
||||||
|
onSaveRef.current(draft.filter((c) => c.name.trim()));
|
||||||
|
}
|
||||||
|
}, [hideButtons, draft]);
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (draft.length !== initialChoices.length) return true;
|
if (draft.length !== initialChoices.length) return true;
|
||||||
@@ -195,16 +210,20 @@ export function ChoiceEditor({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider />
|
{!hideButtons && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<Group justify="flex-end" gap="xs">
|
<Group justify="flex-end" gap="xs">
|
||||||
<Button variant="default" size="xs" onClick={handleCancel}>
|
<Button variant="default" size="xs" onClick={handleCancel}>
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
|
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
|
||||||
{t("Save")}
|
{t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
Portal,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
UnstyledButton,
|
||||||
|
Text,
|
||||||
|
ScrollArea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPlus, IconChevronRight } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BasePropertyType,
|
||||||
|
IBaseProperty,
|
||||||
|
TypeOptions,
|
||||||
|
} from "@/features/base/types/base.types";
|
||||||
|
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||||
|
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||||
|
import { PropertyOptions } from "./property-options";
|
||||||
|
import classes from "@/features/base/styles/grid.module.css";
|
||||||
|
|
||||||
|
type CreatePropertyPopoverProps = {
|
||||||
|
baseId: string;
|
||||||
|
onPropertyCreated?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Panel = "typePicker" | "configure" | "confirmDiscard";
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
// Keep in sync with the switch cases in PropertyOptions
|
||||||
|
const typesWithOptions = new Set<BasePropertyType>([
|
||||||
|
"select",
|
||||||
|
"multiSelect",
|
||||||
|
"status",
|
||||||
|
"number",
|
||||||
|
"date",
|
||||||
|
"person",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [panel, setPanel] = useState<Panel>("typePicker");
|
||||||
|
const [selectedType, setSelectedType] = useState<BasePropertyType | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [typeOptions, setTypeOptions] = useState<Record<string, unknown>>({});
|
||||||
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const createPropertyMutation = useCreatePropertyMutation();
|
||||||
|
|
||||||
|
const selectedTypeDef = useMemo(
|
||||||
|
() => propertyTypes.find((pt) => pt.type === selectedType),
|
||||||
|
[selectedType],
|
||||||
|
);
|
||||||
|
const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
|
||||||
|
const selectedTypeIcon = selectedTypeDef?.icon;
|
||||||
|
|
||||||
|
const hasContent = useMemo(() => {
|
||||||
|
return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
|
||||||
|
}, [name, typeOptions]);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setPanel("typePicker");
|
||||||
|
setSelectedType(null);
|
||||||
|
setName("");
|
||||||
|
setTypeOptions({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
resetState();
|
||||||
|
setOpened(true);
|
||||||
|
}, [resetState]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpened(false);
|
||||||
|
resetState();
|
||||||
|
}, [resetState]);
|
||||||
|
|
||||||
|
const attemptClose = useCallback(() => {
|
||||||
|
if (panel === "configure" && hasContent) {
|
||||||
|
setPanel("confirmDiscard");
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}, [panel, hasContent, handleClose]);
|
||||||
|
|
||||||
|
const handleConfirmDiscard = useCallback(() => {
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
|
const handleCancelDiscard = useCallback(() => {
|
||||||
|
setPanel("configure");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTypeSelect = useCallback((type: BasePropertyType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
setTypeOptions({});
|
||||||
|
setPanel("configure");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (panel === "configure") {
|
||||||
|
setTimeout(() => nameInputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [panel]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
const finalName = name.trim() || selectedTypeLabel;
|
||||||
|
createPropertyMutation.mutate(
|
||||||
|
{
|
||||||
|
baseId,
|
||||||
|
name: finalName,
|
||||||
|
type: selectedType,
|
||||||
|
typeOptions: Object.keys(typeOptions).length > 0
|
||||||
|
? typeOptions as TypeOptions
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onPropertyCreated?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
handleClose();
|
||||||
|
}, [selectedType, name, selectedTypeLabel, typeOptions, baseId, createPropertyMutation, handleClose, onPropertyCreated]);
|
||||||
|
|
||||||
|
const handleBackToTypePicker = useCallback(() => {
|
||||||
|
setPanel("typePicker");
|
||||||
|
setTypeOptions({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (panel === "confirmDiscard") {
|
||||||
|
handleCancelDiscard();
|
||||||
|
} else if (panel === "configure") {
|
||||||
|
handleBackToTypePicker();
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNameKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleCreate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionsUpdate = useCallback(
|
||||||
|
(newTypeOptions: Record<string, unknown>) => {
|
||||||
|
setTypeOptions(newTypeOptions);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syntheticProperty: IBaseProperty = useMemo(() => ({
|
||||||
|
id: "",
|
||||||
|
baseId,
|
||||||
|
name: name || "",
|
||||||
|
type: selectedType ?? "text",
|
||||||
|
position: "",
|
||||||
|
typeOptions: typeOptions as TypeOptions,
|
||||||
|
isPrimary: false,
|
||||||
|
workspaceId: "",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
}), [baseId, name, selectedType, typeOptions]);
|
||||||
|
|
||||||
|
const TypeIcon = selectedTypeIcon;
|
||||||
|
const showOptions = selectedType && typesWithOptions.has(selectedType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{opened && (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 299,
|
||||||
|
}}
|
||||||
|
onClick={attemptClose}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onClose={noop}
|
||||||
|
position="bottom-start"
|
||||||
|
shadow="md"
|
||||||
|
width={320}
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<div
|
||||||
|
className={classes.addColumnButton}
|
||||||
|
onClick={handleOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown
|
||||||
|
p={0}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{ zIndex: 300 }}
|
||||||
|
>
|
||||||
|
{panel === "typePicker" && (
|
||||||
|
<Stack gap={0} p={4}>
|
||||||
|
<PropertyTypePicker
|
||||||
|
onSelect={handleTypeSelect}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{(panel === "configure" || panel === "confirmDiscard") && (
|
||||||
|
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||||
|
<TextInput
|
||||||
|
ref={nameInputRef}
|
||||||
|
size="xs"
|
||||||
|
placeholder={selectedTypeLabel}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={handleBackToTypePicker}
|
||||||
|
py={6}
|
||||||
|
px={0}
|
||||||
|
mb={showOptions ? "xs" : 0}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{TypeIcon && <TypeIcon size={14} />}
|
||||||
|
<Text size="sm" style={{ flex: 1 }}>
|
||||||
|
{selectedTypeLabel}
|
||||||
|
</Text>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
{showOptions && (
|
||||||
|
<>
|
||||||
|
<Divider mb="xs" />
|
||||||
|
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
|
||||||
|
<PropertyOptions
|
||||||
|
property={syntheticProperty}
|
||||||
|
onUpdate={handleOptionsUpdate}
|
||||||
|
onClose={noop}
|
||||||
|
onDirtyChange={noop}
|
||||||
|
hideButtons
|
||||||
|
/>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
<Button variant="default" size="xs" onClick={attemptClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" onClick={handleCreate}>
|
||||||
|
{t("Create field")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{panel === "confirmDiscard" && (
|
||||||
|
<Stack gap="xs" p="sm">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("Unsaved changes")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("You have unsaved changes. Do you want to discard them?")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleCancelDiscard}
|
||||||
|
>
|
||||||
|
{t("Keep editing")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleConfirmDiscard}
|
||||||
|
>
|
||||||
|
{t("Discard")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
SelectTypeOptions,
|
SelectTypeOptions,
|
||||||
NumberTypeOptions,
|
NumberTypeOptions,
|
||||||
DateTypeOptions,
|
DateTypeOptions,
|
||||||
|
PersonTypeOptions,
|
||||||
Choice,
|
Choice,
|
||||||
} from "@/features/base/types/base.types";
|
} from "@/features/base/types/base.types";
|
||||||
import { ChoiceEditor } from "./choice-editor";
|
import { ChoiceEditor } from "./choice-editor";
|
||||||
@@ -15,9 +16,10 @@ type PropertyOptionsProps = {
|
|||||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
hideButtons?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }: PropertyOptionsProps) {
|
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange, hideButtons }: PropertyOptionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
switch (property.type) {
|
switch (property.type) {
|
||||||
@@ -29,6 +31,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
|
hideButtons={hideButtons}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "status":
|
case "status":
|
||||||
@@ -38,6 +41,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
|
hideButtons={hideButtons}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "number":
|
case "number":
|
||||||
@@ -54,6 +58,13 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "person":
|
||||||
|
return (
|
||||||
|
<PersonOptions
|
||||||
|
property={property}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -68,11 +79,13 @@ function SelectOptions({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
|
hideButtons,
|
||||||
}: {
|
}: {
|
||||||
property: IBaseProperty;
|
property: IBaseProperty;
|
||||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
hideButtons?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||||
const choices = options?.choices ?? [];
|
const choices = options?.choices ?? [];
|
||||||
@@ -95,6 +108,7 @@ function SelectOptions({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
showCategories={false}
|
showCategories={false}
|
||||||
|
hideButtons={hideButtons}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,11 +118,13 @@ function StatusOptions({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
|
hideButtons,
|
||||||
}: {
|
}: {
|
||||||
property: IBaseProperty;
|
property: IBaseProperty;
|
||||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
hideButtons?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||||
const choices = options?.choices ?? [];
|
const choices = options?.choices ?? [];
|
||||||
@@ -131,6 +147,7 @@ function StatusOptions({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
showCategories
|
showCategories
|
||||||
|
hideButtons={hideButtons}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -215,3 +232,30 @@ function DateOptions({
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PersonOptions({
|
||||||
|
property,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
property: IBaseProperty;
|
||||||
|
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const options = property.typeOptions as PersonTypeOptions | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
label={t("Allow multiple people")}
|
||||||
|
checked={options?.allowMultiple !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate({
|
||||||
|
...property.typeOptions,
|
||||||
|
allowMultiple: e.currentTarget.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UnstyledButton, Group, Text } from "@mantine/core";
|
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconLetterT,
|
IconLetterT,
|
||||||
IconHash,
|
IconHash,
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
IconClockEdit,
|
IconClockEdit,
|
||||||
IconUserEdit,
|
IconUserEdit,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconSearch,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BasePropertyType } from "@/features/base/types/base.types";
|
import { BasePropertyType } from "@/features/base/types/base.types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import classes from "@/features/base/styles/cells.module.css";
|
import classes from "@/features/base/styles/cells.module.css";
|
||||||
|
|
||||||
const propertyTypes: {
|
const propertyTypes: {
|
||||||
@@ -45,21 +47,46 @@ type PropertyTypePickerProps = {
|
|||||||
onSelect: (type: BasePropertyType) => void;
|
onSelect: (type: BasePropertyType) => void;
|
||||||
currentType?: BasePropertyType;
|
currentType?: BasePropertyType;
|
||||||
excludeTypes?: Set<BasePropertyType>;
|
excludeTypes?: Set<BasePropertyType>;
|
||||||
|
showSearch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PropertyTypePicker({
|
export function PropertyTypePicker({
|
||||||
onSelect,
|
onSelect,
|
||||||
currentType,
|
currentType,
|
||||||
excludeTypes,
|
excludeTypes,
|
||||||
|
showSearch,
|
||||||
}: PropertyTypePickerProps) {
|
}: PropertyTypePickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const types = excludeTypes
|
useEffect(() => {
|
||||||
? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
|
if (showSearch) {
|
||||||
: propertyTypes;
|
setTimeout(() => searchRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [showSearch]);
|
||||||
|
|
||||||
|
const types = propertyTypes
|
||||||
|
.filter(({ type }) => !excludeTypes?.has(type))
|
||||||
|
.filter(({ labelKey }) =>
|
||||||
|
!search || t(labelKey).toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{showSearch && (
|
||||||
|
<TextInput
|
||||||
|
ref={searchRef}
|
||||||
|
size="xs"
|
||||||
|
placeholder={t("Find a field type")}
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
mx="sm"
|
||||||
|
mt="sm"
|
||||||
|
mb={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{types.map(({ type, icon: Icon, labelKey }) => (
|
{types.map(({ type, icon: Icon, labelKey }) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
key={type}
|
key={type}
|
||||||
|
|||||||
@@ -33,10 +33,19 @@ export function useBaseRowsQuery(
|
|||||||
filters?: ViewFilterConfig[],
|
filters?: ViewFilterConfig[],
|
||||||
sorts?: ViewSortConfig[],
|
sorts?: ViewSortConfig[],
|
||||||
) {
|
) {
|
||||||
|
// Normalize empty arrays to undefined so query keys stay stable
|
||||||
|
const activeFilters = filters?.length ? filters : undefined;
|
||||||
|
const activeSorts = sorts?.length ? sorts : undefined;
|
||||||
|
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["base-rows", baseId, filters, sorts],
|
queryKey: ["base-rows", baseId, activeFilters, activeSorts],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
listRows(baseId!, { cursor: pageParam, limit: 100, filters, sorts }),
|
listRows(baseId!, {
|
||||||
|
cursor: pageParam,
|
||||||
|
limit: 100,
|
||||||
|
filters: activeFilters,
|
||||||
|
sorts: activeSorts,
|
||||||
|
}),
|
||||||
enabled: !!baseId,
|
enabled: !!baseId,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export type EmailTypeOptions = {
|
|||||||
defaultValue?: string | null;
|
defaultValue?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PersonTypeOptions = {
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type TypeOptions =
|
export type TypeOptions =
|
||||||
| SelectTypeOptions
|
| SelectTypeOptions
|
||||||
| NumberTypeOptions
|
| NumberTypeOptions
|
||||||
@@ -67,6 +71,7 @@ export type TypeOptions =
|
|||||||
| CheckboxTypeOptions
|
| CheckboxTypeOptions
|
||||||
| UrlTypeOptions
|
| UrlTypeOptions
|
||||||
| EmailTypeOptions
|
| EmailTypeOptions
|
||||||
|
| PersonTypeOptions
|
||||||
| Record<string, unknown>;
|
| Record<string, unknown>;
|
||||||
|
|
||||||
export type IBaseProperty = {
|
export type IBaseProperty = {
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const emailTypeOptionsSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
export const personTypeOptionsSchema = z
|
||||||
|
.object({
|
||||||
|
allowMultiple: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const emptyTypeOptionsSchema = z.object({}).passthrough();
|
export const emptyTypeOptionsSchema = z.object({}).passthrough();
|
||||||
|
|
||||||
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
||||||
@@ -106,7 +112,7 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
|||||||
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
|
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
|
||||||
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
|
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
|
||||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||||
[BasePropertyType.PERSON]: emptyTypeOptionsSchema,
|
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
||||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||||
@@ -145,7 +151,7 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
|||||||
[BasePropertyType.STATUS]: z.string().uuid(),
|
[BasePropertyType.STATUS]: z.string().uuid(),
|
||||||
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
|
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
|
||||||
[BasePropertyType.DATE]: z.string(),
|
[BasePropertyType.DATE]: z.string(),
|
||||||
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
|
[BasePropertyType.PERSON]: z.union([z.string().uuid(), z.array(z.string().uuid())]),
|
||||||
[BasePropertyType.FILE]: z.array(z.object({
|
[BasePropertyType.FILE]: z.array(z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
fileName: z.string(),
|
fileName: z.string(),
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { v7 as uuid7 } from 'uuid';
|
import { v7 as uuid7 } from 'uuid';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
|
|
||||||
const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
|
const TOTAL_ROWS = 1500;
|
||||||
const TOTAL_ROWS = 100_000;
|
|
||||||
const BATCH_SIZE = 2000;
|
const BATCH_SIZE = 2000;
|
||||||
|
|
||||||
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
|
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||||
@@ -48,6 +47,10 @@ const WORDS = [
|
|||||||
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
|
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink', 'gray',
|
||||||
|
];
|
||||||
|
|
||||||
function randomWords(min: number, max: number): string {
|
function randomWords(min: number, max: number): string {
|
||||||
const count = min + Math.floor(Math.random() * (max - min + 1));
|
const count = min + Math.floor(Math.random() * (max - min + 1));
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
@@ -57,6 +60,60 @@ function randomWords(min: number, max: number): string {
|
|||||||
return result.join(' ');
|
return result.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeChoices(names: string[], category?: string) {
|
||||||
|
return names.map((name, i) => ({
|
||||||
|
id: uuid7(),
|
||||||
|
name,
|
||||||
|
color: COLORS[i % COLORS.length],
|
||||||
|
...(category ? {} : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStatusChoices() {
|
||||||
|
const todo = [{ id: uuid7(), name: 'Not Started', color: 'gray', category: 'todo' }];
|
||||||
|
const inProgress = [
|
||||||
|
{ id: uuid7(), name: 'In Progress', color: 'blue', category: 'inProgress' },
|
||||||
|
{ id: uuid7(), name: 'In Review', color: 'purple', category: 'inProgress' },
|
||||||
|
];
|
||||||
|
const complete = [
|
||||||
|
{ id: uuid7(), name: 'Done', color: 'green', category: 'complete' },
|
||||||
|
{ id: uuid7(), name: 'Cancelled', color: 'red', category: 'complete' },
|
||||||
|
];
|
||||||
|
const all = [...todo, ...inProgress, ...complete];
|
||||||
|
return { choices: all, choiceOrder: all.map((c) => c.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertyDef = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
typeOptions?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPropertyDefinitions(): PropertyDef[] {
|
||||||
|
const priorityChoices = makeChoices(['Low', 'Medium', 'High', 'Critical']);
|
||||||
|
const categoryChoices = makeChoices(['Engineering', 'Design', 'Marketing', 'Sales', 'Support', 'Operations']);
|
||||||
|
const tagChoices = makeChoices(['Bug', 'Feature', 'Improvement', 'Documentation', 'Research']);
|
||||||
|
const statusOpts = makeStatusChoices();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'Title', type: 'text', isPrimary: true },
|
||||||
|
{ name: 'Status', type: 'status', typeOptions: statusOpts },
|
||||||
|
{ name: 'Priority', type: 'select', typeOptions: { choices: priorityChoices, choiceOrder: priorityChoices.map((c) => c.id) } },
|
||||||
|
{ name: 'Category', type: 'select', typeOptions: { choices: categoryChoices, choiceOrder: categoryChoices.map((c) => c.id) } },
|
||||||
|
{ name: 'Tags', type: 'multiSelect', typeOptions: { choices: tagChoices, choiceOrder: tagChoices.map((c) => c.id) } },
|
||||||
|
{ name: 'Due Date', type: 'date', typeOptions: { dateFormat: 'YYYY-MM-DD', includeTime: false } },
|
||||||
|
{ name: 'Estimate', type: 'number', typeOptions: { format: 'plain', precision: 1 } },
|
||||||
|
{ name: 'Budget', type: 'number', typeOptions: { format: 'currency', precision: 2, currencySymbol: '$' } },
|
||||||
|
{ name: 'Approved', type: 'checkbox' },
|
||||||
|
{ name: 'Website', type: 'url' },
|
||||||
|
{ name: 'Contact Email', type: 'email' },
|
||||||
|
{ name: 'Notes', type: 'text' },
|
||||||
|
{ name: 'Created', type: 'createdAt' },
|
||||||
|
{ name: 'Last Edited', type: 'lastEditedAt' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
type CellGenerator = () => unknown;
|
type CellGenerator = () => unknown;
|
||||||
|
|
||||||
function buildCellGenerator(property: any): CellGenerator | null {
|
function buildCellGenerator(property: any): CellGenerator | null {
|
||||||
@@ -108,17 +165,80 @@ function buildCellGenerator(property: any): CellGenerator | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function createBase(workspaceId: string, spaceId: string, creatorId: string | null): Promise<string> {
|
||||||
console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
|
const baseId = uuid7();
|
||||||
|
const baseName = `Seed Base ${new Date().toISOString().slice(0, 16)}`;
|
||||||
|
|
||||||
const base = await db
|
await db.insertInto('bases').values({
|
||||||
.selectFrom('bases')
|
id: baseId,
|
||||||
.selectAll()
|
name: baseName,
|
||||||
.where('id', '=', BASE_ID)
|
space_id: spaceId,
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
creator_id: creatorId,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).execute();
|
||||||
|
|
||||||
|
console.log(`Created base: ${baseName}`);
|
||||||
|
console.log(`Base ID: ${baseId}\n`);
|
||||||
|
|
||||||
|
// Create properties
|
||||||
|
const propertyDefs = buildPropertyDefinitions();
|
||||||
|
let propPosition: string | null = null;
|
||||||
|
const insertedProperties: any[] = [];
|
||||||
|
|
||||||
|
for (const def of propertyDefs) {
|
||||||
|
propPosition = generateJitteredKeyBetween(propPosition, null);
|
||||||
|
const prop = {
|
||||||
|
id: uuid7(),
|
||||||
|
base_id: baseId,
|
||||||
|
name: def.name,
|
||||||
|
type: def.type,
|
||||||
|
position: propPosition,
|
||||||
|
type_options: def.typeOptions ? JSON.stringify(def.typeOptions) : null,
|
||||||
|
is_primary: def.isPrimary ?? false,
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
};
|
||||||
|
insertedProperties.push(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insertInto('base_properties').values(insertedProperties).execute();
|
||||||
|
console.log(`Created ${insertedProperties.length} properties:`);
|
||||||
|
for (const p of insertedProperties) {
|
||||||
|
console.log(` - ${p.name} (${p.type})${p.is_primary ? ' [primary]' : ''}${SKIP_TYPES.has(p.type) ? ' [system]' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default view
|
||||||
|
const viewId = uuid7();
|
||||||
|
await db.insertInto('base_views').values({
|
||||||
|
id: viewId,
|
||||||
|
base_id: baseId,
|
||||||
|
name: 'Table View 1',
|
||||||
|
type: 'table',
|
||||||
|
position: generateJitteredKeyBetween(null, null),
|
||||||
|
config: JSON.stringify({}),
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
creator_id: creatorId,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).execute();
|
||||||
|
console.log(`Created view: Table View 1\n`);
|
||||||
|
|
||||||
|
return baseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const spaceId = '019c69a3-dd47-7014-8b87-ec8f167577ee';
|
||||||
|
|
||||||
|
const space = await db
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.select(['id', 'workspace_id'])
|
||||||
|
.where('id', '=', spaceId)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const workspaceId = base.workspace_id;
|
const workspaceId = space.workspace_id;
|
||||||
console.log(`Workspace: ${workspaceId}`);
|
|
||||||
|
|
||||||
const user = await db
|
const user = await db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
@@ -127,19 +247,21 @@ async function main() {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
const creatorId = user?.id ?? null;
|
const creatorId = user?.id ?? null;
|
||||||
console.log(`Creator: ${creatorId ?? '(none)'}`);
|
|
||||||
|
|
||||||
|
console.log(`Workspace: ${workspaceId}`);
|
||||||
|
console.log(`Space: ${spaceId}`);
|
||||||
|
console.log(`Creator: ${creatorId ?? '(none)'}\n`);
|
||||||
|
|
||||||
|
// Create the base with properties and view
|
||||||
|
const baseId = await createBase(workspaceId, spaceId, creatorId);
|
||||||
|
|
||||||
|
// Load the created properties for cell generation
|
||||||
const properties = await db
|
const properties = await db
|
||||||
.selectFrom('base_properties')
|
.selectFrom('base_properties')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('base_id', '=', BASE_ID)
|
.where('base_id', '=', baseId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
console.log(`Properties: ${properties.length}`);
|
|
||||||
for (const p of properties) {
|
|
||||||
console.log(` - ${p.name} (${p.type})${SKIP_TYPES.has(p.type) ? ' [skipped]' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
|
const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
|
||||||
for (const prop of properties) {
|
for (const prop of properties) {
|
||||||
const gen = buildCellGenerator(prop);
|
const gen = buildCellGenerator(prop);
|
||||||
@@ -148,18 +270,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nGenerating ${TOTAL_ROWS.toLocaleString()} positions...`);
|
console.log(`Generating ${TOTAL_ROWS.toLocaleString()} positions...`);
|
||||||
|
|
||||||
const lastRow = await db
|
let lastPosition: string | null = null;
|
||||||
.selectFrom('base_rows')
|
|
||||||
.select('position')
|
|
||||||
.where('base_id', '=', BASE_ID)
|
|
||||||
.where('deleted_at', 'is', null)
|
|
||||||
.orderBy(sql`position COLLATE "C"`, sql`desc`)
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
let lastPosition: string | null = lastRow?.position ?? null;
|
|
||||||
const positions: string[] = new Array(TOTAL_ROWS);
|
const positions: string[] = new Array(TOTAL_ROWS);
|
||||||
for (let i = 0; i < TOTAL_ROWS; i++) {
|
for (let i = 0; i < TOTAL_ROWS; i++) {
|
||||||
lastPosition = generateJitteredKeyBetween(lastPosition, null);
|
lastPosition = generateJitteredKeyBetween(lastPosition, null);
|
||||||
@@ -182,7 +295,7 @@ async function main() {
|
|||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
base_id: BASE_ID,
|
base_id: baseId,
|
||||||
cells,
|
cells,
|
||||||
position: positions[i],
|
position: positions[i],
|
||||||
creator_id: creatorId,
|
creator_id: creatorId,
|
||||||
@@ -201,6 +314,7 @@ async function main() {
|
|||||||
|
|
||||||
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
|
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
|
||||||
|
console.log(`\nBase ID: ${baseId}`);
|
||||||
|
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user