diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx
index a465cd62..21bb4863 100644
--- a/apps/client/src/features/base/components/base-table.tsx
+++ b/apps/client/src/features/base/components/base-table.tsx
@@ -13,7 +13,6 @@ import {
import { useUpdateRowMutation } 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 { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
@@ -45,7 +44,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
- const createPropertyMutation = useCreatePropertyMutation();
const createViewMutation = useCreateViewMutation();
useEffect(() => {
@@ -76,14 +74,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
createRowMutation.mutate({ baseId });
}, [baseId, createRowMutation]);
- const handleAddColumn = useCallback(() => {
- createPropertyMutation.mutate({
- baseId,
- name: t("New property"),
- type: "text",
- });
- }, [baseId, createPropertyMutation, t]);
-
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
@@ -188,7 +178,7 @@ export function BaseTable({ baseId }: BaseTableProps) {
table={table}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
- onAddColumn={handleAddColumn}
+ baseId={baseId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
diff --git a/apps/client/src/features/base/components/cells/cell-person.tsx b/apps/client/src/features/base/components/cells/cell-person.tsx
index dcdafcca..489bd54d 100644
--- a/apps/client/src/features/base/components/cells/cell-person.tsx
+++ b/apps/client/src/features/base/components/cells/cell-person.tsx
@@ -1,7 +1,10 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover } from "@mantine/core";
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 { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
@@ -17,10 +20,14 @@ type CellPersonProps = {
export function CellPerson({
value,
+ property,
isEditing,
onCommit,
onCancel,
}: CellPersonProps) {
+ const allowMultiple =
+ (property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
+
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
@@ -53,31 +60,38 @@ export function CellPerson({
)
: members;
- const handleAdd = useCallback(
+ const handleSelect = useCallback(
(memberId: string) => {
- if (personIds.includes(memberId)) return;
- onCommit([...personIds, memberId]);
+ if (allowMultiple) {
+ // 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(
(memberId: string) => {
- 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);
+ if (allowMultiple) {
+ const newIds = personIds.filter((id) => id !== memberId);
+ onCommit(newIds.length > 0 ? newIds : null);
} else {
- handleAdd(memberId);
+ onCommit(null);
}
},
- [personIds, handleAdd, handleRemove],
+ [allowMultiple, personIds, onCommit],
);
const handleKeyDown = useCallback(
@@ -150,9 +164,11 @@ export function CellPerson({
{/* Dropdown */}
-
- Select as many as you like
-
+ {allowMultiple && (
+
+ Select as many as you like
+
+ )}
{filteredMembers.map((member) => (
handleToggle(member.id)}
+ onClick={() => handleSelect(member.id)}
>
;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
- onAddColumn?: () => void;
+ baseId?: string;
onColumnReorder?: (columnId: string, overColumnId: string) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
@@ -46,7 +46,7 @@ export function GridContainer({
table,
onCellUpdate,
onAddRow,
- onAddColumn,
+ baseId,
onColumnReorder,
onResizeEnd,
onRowReorder,
@@ -115,8 +115,8 @@ export function GridContainer({
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
- return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
- }, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
+ return columnWidths.join(" ") + (baseId ? " 40px" : "");
+ }, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
const totalHeight = virtualizer.getTotalSize();
@@ -148,6 +148,18 @@ export function GridContainer({
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(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@@ -194,7 +206,12 @@ export function GridContainer({
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
-
+
{paddingTop > 0 && (
diff --git a/apps/client/src/features/base/components/grid/grid-header.tsx b/apps/client/src/features/base/components/grid/grid-header.tsx
index f418df07..add400fe 100644
--- a/apps/client/src/features/base/components/grid/grid-header.tsx
+++ b/apps/client/src/features/base/components/grid/grid-header.tsx
@@ -1,39 +1,38 @@
-import { memo, useCallback } from "react";
-import { Table } from "@tanstack/react-table";
+import { memo } from "react";
+import { Table, ColumnOrderState } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
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";
type GridHeaderProps = {
table: Table;
- 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({
table,
- onAddColumn,
+ baseId,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ columnOrder: _columnOrder,
+ onPropertyCreated,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
- const handleAddColumn = useCallback(() => {
- onAddColumn?.();
- }, [onAddColumn]);
-
return (
{headerGroups[0]?.headers.map((header) => (
))}
- {onAddColumn && (
-
-
-
+ {baseId && (
+
)}
);
diff --git a/apps/client/src/features/base/components/property/choice-editor.tsx b/apps/client/src/features/base/components/property/choice-editor.tsx
index 5fbe976c..de9aef04 100644
--- a/apps/client/src/features/base/components/property/choice-editor.tsx
+++ b/apps/client/src/features/base/components/property/choice-editor.tsx
@@ -54,6 +54,7 @@ type ChoiceEditorProps = {
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
+ hideButtons?: boolean;
};
export function ChoiceEditor({
@@ -62,14 +63,28 @@ export function ChoiceEditor({
onClose,
onDirtyChange,
showCategories = false,
+ hideButtons = false,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState(null);
+ // Sync from parent only when not in live mode (hideButtons = create flow)
useEffect(() => {
- setDraft(initialChoices);
- }, [initialChoices]);
+ if (!hideButtons) {
+ 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(() => {
if (draft.length !== initialChoices.length) return true;
@@ -195,16 +210,20 @@ export function ChoiceEditor({
/>
)}
-
+ {!hideButtons && (
+ <>
+
-
-
-
-
+
+
+
+
+ >
+ )}
);
}
diff --git a/apps/client/src/features/base/components/property/create-property-popover.tsx b/apps/client/src/features/base/components/property/create-property-popover.tsx
new file mode 100644
index 00000000..509feb93
--- /dev/null
+++ b/apps/client/src/features/base/components/property/create-property-popover.tsx
@@ -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([
+ "select",
+ "multiSelect",
+ "status",
+ "number",
+ "date",
+ "person",
+]);
+
+export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+ const [panel, setPanel] = useState("typePicker");
+ const [selectedType, setSelectedType] = useState(null);
+ const [name, setName] = useState("");
+ const [typeOptions, setTypeOptions] = useState>({});
+ const nameInputRef = useRef(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) => {
+ 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 && (
+
+
+
+ )}
+
+
+
+
+
+
+ e.stopPropagation()}
+ onKeyDown={handleKeyDown}
+ style={{ zIndex: 300 }}
+ >
+ {panel === "typePicker" && (
+
+
+
+ )}
+ {(panel === "configure" || panel === "confirmDiscard") && (
+
+ setName(e.currentTarget.value)}
+ onKeyDown={handleNameKeyDown}
+ mb="xs"
+ />
+
+
+ {TypeIcon && }
+
+ {selectedTypeLabel}
+
+
+
+
+
+ {showOptions && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+ {panel === "confirmDiscard" && (
+
+
+ {t("Unsaved changes")}
+
+
+ {t("You have unsaved changes. Do you want to discard them?")}
+
+
+
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/base/components/property/property-options.tsx b/apps/client/src/features/base/components/property/property-options.tsx
index 7a34e69b..6754c059 100644
--- a/apps/client/src/features/base/components/property/property-options.tsx
+++ b/apps/client/src/features/base/components/property/property-options.tsx
@@ -5,6 +5,7 @@ import {
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
+ PersonTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
@@ -15,9 +16,10 @@ type PropertyOptionsProps = {
onUpdate: (typeOptions: Record) => void;
onClose: () => 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();
switch (property.type) {
@@ -29,6 +31,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
+ hideButtons={hideButtons}
/>
);
case "status":
@@ -38,6 +41,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
+ hideButtons={hideButtons}
/>
);
case "number":
@@ -54,6 +58,13 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
onUpdate={onUpdate}
/>
);
+ case "person":
+ return (
+
+ );
default:
return (
@@ -68,11 +79,13 @@ function SelectOptions({
onUpdate,
onClose,
onDirtyChange,
+ hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
+ hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
@@ -95,6 +108,7 @@ function SelectOptions({
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
+ hideButtons={hideButtons}
/>
);
}
@@ -104,11 +118,13 @@ function StatusOptions({
onUpdate,
onClose,
onDirtyChange,
+ hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
+ hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
@@ -131,6 +147,7 @@ function StatusOptions({
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
+ hideButtons={hideButtons}
/>
);
}
@@ -215,3 +232,30 @@ function DateOptions({
);
}
+
+function PersonOptions({
+ property,
+ onUpdate,
+}: {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+}) {
+ const { t } = useTranslation();
+ const options = property.typeOptions as PersonTypeOptions | undefined;
+
+ return (
+
+
+ onUpdate({
+ ...property.typeOptions,
+ allowMultiple: e.currentTarget.checked,
+ })
+ }
+ />
+
+ );
+}
diff --git a/apps/client/src/features/base/components/property/property-type-picker.tsx b/apps/client/src/features/base/components/property/property-type-picker.tsx
index 5d7008ab..cb9e40c7 100644
--- a/apps/client/src/features/base/components/property/property-type-picker.tsx
+++ b/apps/client/src/features/base/components/property/property-type-picker.tsx
@@ -1,4 +1,4 @@
-import { UnstyledButton, Group, Text } from "@mantine/core";
+import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import {
IconLetterT,
IconHash,
@@ -15,9 +15,11 @@ import {
IconClockEdit,
IconUserEdit,
IconCheck,
+ IconSearch,
} from "@tabler/icons-react";
import { BasePropertyType } from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
+import { useState, useRef, useEffect } from "react";
import classes from "@/features/base/styles/cells.module.css";
const propertyTypes: {
@@ -45,21 +47,46 @@ type PropertyTypePickerProps = {
onSelect: (type: BasePropertyType) => void;
currentType?: BasePropertyType;
excludeTypes?: Set;
+ showSearch?: boolean;
};
export function PropertyTypePicker({
onSelect,
currentType,
excludeTypes,
+ showSearch,
}: PropertyTypePickerProps) {
const { t } = useTranslation();
+ const [search, setSearch] = useState("");
+ const searchRef = useRef(null);
- const types = excludeTypes
- ? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
- : propertyTypes;
+ useEffect(() => {
+ if (showSearch) {
+ setTimeout(() => searchRef.current?.focus(), 0);
+ }
+ }, [showSearch]);
+
+ const types = propertyTypes
+ .filter(({ type }) => !excludeTypes?.has(type))
+ .filter(({ labelKey }) =>
+ !search || t(labelKey).toLowerCase().includes(search.toLowerCase())
+ );
return (
<>
+ {showSearch && (
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ mx="sm"
+ mt="sm"
+ mb={4}
+ />
+ )}
{types.map(({ type, icon: Icon, labelKey }) => (
- listRows(baseId!, { cursor: pageParam, limit: 100, filters, sorts }),
+ listRows(baseId!, {
+ cursor: pageParam,
+ limit: 100,
+ filters: activeFilters,
+ sorts: activeSorts,
+ }),
enabled: !!baseId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IPagination) =>
diff --git a/apps/client/src/features/base/types/base.types.ts b/apps/client/src/features/base/types/base.types.ts
index 8e29024c..bbc58868 100644
--- a/apps/client/src/features/base/types/base.types.ts
+++ b/apps/client/src/features/base/types/base.types.ts
@@ -59,6 +59,10 @@ export type EmailTypeOptions = {
defaultValue?: string | null;
};
+export type PersonTypeOptions = {
+ allowMultiple?: boolean;
+};
+
export type TypeOptions =
| SelectTypeOptions
| NumberTypeOptions
@@ -67,6 +71,7 @@ export type TypeOptions =
| CheckboxTypeOptions
| UrlTypeOptions
| EmailTypeOptions
+ | PersonTypeOptions
| Record;
export type IBaseProperty = {
diff --git a/apps/server/src/core/base/base.schemas.ts b/apps/server/src/core/base/base.schemas.ts
index eb9e82e6..86522878 100644
--- a/apps/server/src/core/base/base.schemas.ts
+++ b/apps/server/src/core/base/base.schemas.ts
@@ -97,6 +97,12 @@ export const emailTypeOptionsSchema = z
})
.passthrough();
+export const personTypeOptionsSchema = z
+ .object({
+ allowMultiple: z.boolean().default(true),
+ })
+ .passthrough();
+
export const emptyTypeOptionsSchema = z.object({}).passthrough();
const typeOptionsSchemaMap: Record = {
@@ -106,7 +112,7 @@ const typeOptionsSchemaMap: Record = {
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
[BasePropertyType.DATE]: dateTypeOptionsSchema,
- [BasePropertyType.PERSON]: emptyTypeOptionsSchema,
+ [BasePropertyType.PERSON]: personTypeOptionsSchema,
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
[BasePropertyType.URL]: urlTypeOptionsSchema,
@@ -145,7 +151,7 @@ const cellValueSchemaMap: Partial> = {
[BasePropertyType.STATUS]: z.string().uuid(),
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
[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({
id: z.string().uuid(),
fileName: z.string(),
diff --git a/apps/server/src/scripts/seed-base-rows.ts b/apps/server/src/scripts/seed-base-rows.ts
index 1596b0d8..a9e2ef39 100644
--- a/apps/server/src/scripts/seed-base-rows.ts
+++ b/apps/server/src/scripts/seed-base-rows.ts
@@ -1,13 +1,12 @@
import * as path from 'path';
import * as dotenv from 'dotenv';
-import { Kysely, sql } from 'kysely';
+import { Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
import { v7 as uuid7 } from 'uuid';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
-const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
-const TOTAL_ROWS = 100_000;
+const TOTAL_ROWS = 1500;
const BATCH_SIZE = 2000;
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
@@ -48,6 +47,10 @@ const WORDS = [
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
];
+const COLORS = [
+ 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink', 'gray',
+];
+
function randomWords(min: number, max: number): string {
const count = min + Math.floor(Math.random() * (max - min + 1));
const result: string[] = [];
@@ -57,6 +60,60 @@ function randomWords(min: number, max: number): string {
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;
function buildCellGenerator(property: any): CellGenerator | null {
@@ -108,17 +165,80 @@ function buildCellGenerator(property: any): CellGenerator | null {
}
}
-async function main() {
- console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
+async function createBase(workspaceId: string, spaceId: string, creatorId: string | null): Promise {
+ const baseId = uuid7();
+ const baseName = `Seed Base ${new Date().toISOString().slice(0, 16)}`;
- const base = await db
- .selectFrom('bases')
- .selectAll()
- .where('id', '=', BASE_ID)
+ await db.insertInto('bases').values({
+ id: baseId,
+ name: baseName,
+ 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();
- const workspaceId = base.workspace_id;
- console.log(`Workspace: ${workspaceId}`);
+ const workspaceId = space.workspace_id;
const user = await db
.selectFrom('users')
@@ -127,19 +247,21 @@ async function main() {
.executeTakeFirst();
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
.selectFrom('base_properties')
.selectAll()
- .where('base_id', '=', BASE_ID)
+ .where('base_id', '=', baseId)
.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 }> = [];
for (const prop of properties) {
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
- .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;
+ let lastPosition: string | null = null;
const positions: string[] = new Array(TOTAL_ROWS);
for (let i = 0; i < TOTAL_ROWS; i++) {
lastPosition = generateJitteredKeyBetween(lastPosition, null);
@@ -182,7 +295,7 @@ async function main() {
rows.push({
id: uuid7(),
- base_id: BASE_ID,
+ base_id: baseId,
cells,
position: positions[i],
creator_id: creatorId,
@@ -201,6 +314,7 @@ async function main() {
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
+ console.log(`\nBase ID: ${baseId}`);
await db.destroy();
process.exit(0);