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);