From f5b19316af009aaeb3ba886784687a2520049e32 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:13:53 +0100 Subject: [PATCH] Base WIP --- .../features/base/components/base-table.tsx | 8 +- .../features/base/components/base-toolbar.tsx | 31 +- .../base/components/grid/grid-header-cell.tsx | 19 +- .../components/views/view-filter-config.tsx | 124 ++-- .../features/base/hooks/use-base-socket.ts | 223 ++++++ .../base/queries/base-property-query.ts | 8 +- .../features/base/queries/base-row-query.ts | 40 +- .../features/base/services/base-service.ts | 6 +- .../src/features/base/styles/grid.module.css | 5 + .../src/features/base/types/base.types.ts | 67 +- .../src/common/events/event.contants.ts | 21 + apps/server/src/core/base/base.module.ts | 27 +- apps/server/src/core/base/base.schemas.ts | 145 +++- .../controllers/base-property.controller.ts | 26 +- .../base/controllers/base-row.controller.ts | 127 ++-- .../base/controllers/base-view.controller.ts | 24 +- .../src/core/base/dto/create-row.dto.ts | 8 +- .../src/core/base/dto/update-property.dto.ts | 12 + .../src/core/base/dto/update-row.dto.ts | 55 +- apps/server/src/core/base/engine/cursor.ts | 111 +++ apps/server/src/core/base/engine/engine.ts | 86 +++ .../server/src/core/base/engine/extractors.ts | 32 + apps/server/src/core/base/engine/index.ts | 44 ++ apps/server/src/core/base/engine/kinds.ts | 57 ++ apps/server/src/core/base/engine/predicate.ts | 404 +++++++++++ .../server/src/core/base/engine/schema.zod.ts | 100 +++ apps/server/src/core/base/engine/search.ts | 27 + apps/server/src/core/base/engine/sort.ts | 112 +++ .../src/core/base/events/base-events.ts | 47 ++ .../base/processors/base-queue.processor.ts | 177 +++++ .../base/realtime/base-presence.service.ts | 74 ++ .../core/base/realtime/base-ws-consumers.ts | 165 +++++ .../src/core/base/realtime/base-ws.service.ts | 233 +++++++ .../base/services/base-property.service.ts | 483 ++++++++++--- .../core/base/services/base-row.service.ts | 196 ++++-- .../core/base/services/base-view.service.ts | 85 ++- .../src/core/base/services/base.service.ts | 2 +- .../src/core/base/tasks/base-cell-gc.task.ts | 35 + .../base/tasks/base-type-conversion.task.ts | 203 ++++++ .../20260417T120000-bases-hardening.ts | 333 +++++++++ .../20260418T120000-property-pending-type.ts | 24 + .../database/repos/base/base-property.repo.ts | 80 ++- .../src/database/repos/base/base-row.repo.ts | 636 +++++++----------- .../src/database/repos/base/base-view.repo.ts | 37 +- .../src/database/repos/base/base.repo.ts | 19 +- apps/server/src/database/types/db.d.ts | 7 + .../server/src/database/types/entity.types.ts | 15 +- .../queue/constants/queue.constants.ts | 4 + .../queue/constants/queue.interface.ts | 24 + .../src/integrations/queue/queue.module.ts | 8 + apps/server/src/ws/ws.gateway.ts | 20 +- apps/server/src/ws/ws.module.ts | 3 +- apps/server/src/ws/ws.utils.ts | 11 + 53 files changed, 4057 insertions(+), 813 deletions(-) create mode 100644 apps/client/src/features/base/hooks/use-base-socket.ts create mode 100644 apps/server/src/core/base/engine/cursor.ts create mode 100644 apps/server/src/core/base/engine/engine.ts create mode 100644 apps/server/src/core/base/engine/extractors.ts create mode 100644 apps/server/src/core/base/engine/index.ts create mode 100644 apps/server/src/core/base/engine/kinds.ts create mode 100644 apps/server/src/core/base/engine/predicate.ts create mode 100644 apps/server/src/core/base/engine/schema.zod.ts create mode 100644 apps/server/src/core/base/engine/search.ts create mode 100644 apps/server/src/core/base/engine/sort.ts create mode 100644 apps/server/src/core/base/events/base-events.ts create mode 100644 apps/server/src/core/base/processors/base-queue.processor.ts create mode 100644 apps/server/src/core/base/realtime/base-presence.service.ts create mode 100644 apps/server/src/core/base/realtime/base-ws-consumers.ts create mode 100644 apps/server/src/core/base/realtime/base-ws.service.ts create mode 100644 apps/server/src/core/base/tasks/base-cell-gc.task.ts create mode 100644 apps/server/src/core/base/tasks/base-type-conversion.task.ts create mode 100644 apps/server/src/database/migrations/20260417T120000-bases-hardening.ts create mode 100644 apps/server/src/database/migrations/20260418T120000-property-pending-type.ts diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx index 21bb4863..a1d65c30 100644 --- a/apps/client/src/features/base/components/base-table.tsx +++ b/apps/client/src/features/base/components/base-table.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { arrayMove } from "@dnd-kit/sortable"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { useBaseQuery } from "@/features/base/queries/base-query"; +import { useBaseSocket } from "@/features/base/hooks/use-base-socket"; import { useBaseRowsQuery, flattenRows, @@ -26,6 +27,9 @@ type BaseTableProps = { export function BaseTable({ baseId }: BaseTableProps) { const { t } = useTranslation(); + // Subscribe to the base's realtime room so other clients' edits, + // schema changes, and async-job completions reconcile into our cache. + useBaseSocket(baseId); const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId); const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void]; @@ -36,10 +40,10 @@ export function BaseTable({ baseId }: BaseTableProps) { return views.find((v) => v.id === activeViewId) ?? views[0]; }, [views, activeViewId]); - const activeFilters = activeView?.config?.filters; + const activeFilter = activeView?.config?.filter; const activeSorts = activeView?.config?.sorts; const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = - useBaseRowsQuery(baseId, activeFilters, activeSorts); + useBaseRowsQuery(baseId, activeFilter, activeSorts); const updateRowMutation = useUpdateRowMutation(); const createRowMutation = useCreateRowMutation(); diff --git a/apps/client/src/features/base/components/base-toolbar.tsx b/apps/client/src/features/base/components/base-toolbar.tsx index e56a49e6..6990d7eb 100644 --- a/apps/client/src/features/base/components/base-toolbar.tsx +++ b/apps/client/src/features/base/components/base-toolbar.tsx @@ -11,7 +11,8 @@ import { IBaseRow, IBaseView, ViewSortConfig, - ViewFilterConfig, + FilterCondition, + FilterGroup, } from "@/features/base/types/base.types"; import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; import { ViewTabs } from "@/features/base/components/views/view-tabs"; @@ -54,7 +55,16 @@ export function BaseToolbar({ const updateViewMutation = useUpdateViewMutation(); const sorts = activeView?.config?.sorts ?? []; - const filters = activeView?.config?.filters ?? []; + // Stored view config uses the engine's filter tree. The popover edits + // an AND-only flat list; we unwrap the top-level group's children when + // reading and rewrap on save. + const conditions = useMemo(() => { + const filter = activeView?.config?.filter; + if (!filter || filter.op !== "and") return []; + return filter.children.filter( + (c): c is FilterCondition => !("children" in c), + ); + }, [activeView?.config?.filter]); const hiddenFieldCount = useMemo(() => { const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); @@ -74,12 +84,17 @@ export function BaseToolbar({ ); const handleFiltersChange = useCallback( - (newFilters: ViewFilterConfig[]) => { + (newConditions: FilterCondition[]) => { if (!activeView) return; + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + const { filter: _drop, ...rest } = activeView.config ?? {}; updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, - config: { ...activeView.config, filters: newFilters }, + config: filter ? { ...rest, filter } : rest, }); }, [activeView, base.id, updateViewMutation], @@ -99,7 +114,7 @@ export function BaseToolbar({ setFilterOpened(false)} - filters={filters} + conditions={conditions} properties={base.properties} onChange={handleFiltersChange} > @@ -107,11 +122,11 @@ export function BaseToolbar({ 0 ? "blue" : "gray"} + color={conditions.length > 0 ? "blue" : "gray"} onClick={() => openToolbar("filter")} > - {filters.length > 0 && ( + {conditions.length > 0 && ( - {filters.length} + {conditions.length} )} diff --git a/apps/client/src/features/base/components/grid/grid-header-cell.tsx b/apps/client/src/features/base/components/grid/grid-header-cell.tsx index 9d54c80b..23220423 100644 --- a/apps/client/src/features/base/components/grid/grid-header-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-header-cell.tsx @@ -2,7 +2,8 @@ import { memo, useCallback, useRef } from "react"; import { Header, flexRender } from "@tanstack/react-table"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Popover } from "@mantine/core"; +import { Loader, Popover, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types"; import { activePropertyMenuAtom, propertyMenuDirtyAtom, editingCellAtom } from "@/features/base/atoms/base-atoms"; @@ -49,12 +50,14 @@ type GridHeaderCellProps = { export const GridHeaderCell = memo(function GridHeaderCell({ header, }: GridHeaderCellProps) { + const { t } = useTranslation(); const property = header.column.columnDef.meta?.property as | IBaseProperty | undefined; const isRowNumber = header.column.id === "__row_number"; const isPinned = header.column.getIsPinned(); const pinOffset = isPinned ? header.column.getStart("left") : undefined; + const isConverting = !!property?.pendingType; const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void]; const menuOpened = activePropertyMenu === header.column.id; @@ -138,6 +141,20 @@ export const GridHeaderCell = memo(function GridHeaderCell({ {flexRender(header.column.columnDef.header, header.getContext())} + {isConverting && ( + + + + )} )} {header.column.getCanResize() && ( diff --git a/apps/client/src/features/base/components/views/view-filter-config.tsx b/apps/client/src/features/base/components/views/view-filter-config.tsx index 44d211a1..412def29 100644 --- a/apps/client/src/features/base/components/views/view-filter-config.tsx +++ b/apps/client/src/features/base/components/views/view-filter-config.tsx @@ -13,61 +13,69 @@ import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IBaseProperty, SelectTypeOptions, - ViewFilterConfig, - ViewFilterOperator, + FilterCondition, + FilterOperator, } from "@/features/base/types/base.types"; import { useTranslation } from "react-i18next"; -const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [ - { value: "equals", labelKey: "Equals" }, - { value: "notEquals", labelKey: "Not equals" }, +/* + * Operator metadata for the filter popover. Values use the server + * engine's operator set (`core/base/engine/schema.zod.ts`); labels are + * i18n-translated display strings. + */ +const OPERATORS: { value: FilterOperator; labelKey: string }[] = [ + { value: "eq", labelKey: "Equals" }, + { value: "neq", labelKey: "Not equals" }, { value: "contains", labelKey: "Contains" }, - { value: "notContains", labelKey: "Not contains" }, + { value: "ncontains", labelKey: "Not contains" }, { value: "isEmpty", labelKey: "Is empty" }, { value: "isNotEmpty", labelKey: "Is not empty" }, - { value: "greaterThan", labelKey: "Greater than" }, - { value: "lessThan", labelKey: "Less than" }, + { value: "gt", labelKey: "Greater than" }, + { value: "lt", labelKey: "Less than" }, { value: "before", labelKey: "Before" }, { value: "after", labelKey: "After" }, + { value: "any", labelKey: "Any of" }, + { value: "none", labelKey: "None of" }, ]; -const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"]; +const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"]; -function getOperatorsForType(type: string): ViewFilterOperator[] { +function getOperatorsForType(type: string): FilterOperator[] { switch (type) { case "text": case "email": case "url": - return ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"]; + return ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"]; case "number": - return ["equals", "notEquals", "greaterThan", "lessThan", "isEmpty", "isNotEmpty"]; + return ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"]; case "date": case "createdAt": case "lastEditedAt": - return ["equals", "notEquals", "before", "after", "isEmpty", "isNotEmpty"]; + return ["eq", "neq", "before", "after", "isEmpty", "isNotEmpty"]; case "select": case "status": + return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"]; case "multiSelect": - return ["equals", "notEquals", "isEmpty", "isNotEmpty"]; + return ["any", "none", "isEmpty", "isNotEmpty"]; case "checkbox": - return ["equals", "isEmpty", "isNotEmpty"]; + return ["eq", "isEmpty", "isNotEmpty"]; case "person": case "lastEditedBy": - return ["equals", "notEquals", "isEmpty", "isNotEmpty"]; + return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"]; case "file": return ["isEmpty", "isNotEmpty"]; default: - return ["equals", "notEquals", "isEmpty", "isNotEmpty"]; + return ["eq", "neq", "isEmpty", "isNotEmpty"]; } } function FilterValueInput({ - filter, + condition, property, onChange, t, }: { - filter: ViewFilterConfig; + condition: FilterCondition; property: IBaseProperty | undefined; onChange: (value: string) => void; t: (key: string) => string; @@ -77,7 +85,7 @@ function FilterValueInput({ onChange(e.currentTarget.value)} w={100} /> @@ -94,7 +102,7 @@ function FilterValueInput({ handlePropertyChange(index, val)} style={{ flex: 1 }} />