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 }} />