mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
Base WIP
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<FilterCondition[]>(() => {
|
||||
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({
|
||||
<ViewFilterConfigPopover
|
||||
opened={filterOpened}
|
||||
onClose={() => setFilterOpened(false)}
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
properties={base.properties}
|
||||
onChange={handleFiltersChange}
|
||||
>
|
||||
@@ -107,11 +122,11 @@ export function BaseToolbar({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={filters.length > 0 ? "blue" : "gray"}
|
||||
color={conditions.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
{filters.length > 0 && (
|
||||
{conditions.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
@@ -127,7 +142,7 @@ export function BaseToolbar({
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
{filters.length}
|
||||
{conditions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -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({
|
||||
<span className={classes.headerCellName}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{isConverting && (
|
||||
<Tooltip
|
||||
label={t("Converting to {{type}}…", {
|
||||
type: property?.pendingType,
|
||||
})}
|
||||
withArrow
|
||||
>
|
||||
<Loader
|
||||
size={12}
|
||||
color="gray"
|
||||
className={classes.headerConvertingSpinner}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
|
||||
@@ -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({
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -94,7 +102,7 @@ function FilterValueInput({
|
||||
<Select
|
||||
size="xs"
|
||||
data={choiceOptions}
|
||||
value={(filter.value as string) ?? null}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={120}
|
||||
placeholder={t("Select")}
|
||||
@@ -108,7 +116,7 @@ function FilterValueInput({
|
||||
size="xs"
|
||||
type="number"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -123,7 +131,7 @@ function FilterValueInput({
|
||||
{ value: "true", label: t("True") },
|
||||
{ value: "false", label: t("False") },
|
||||
]}
|
||||
value={(filter.value as string) ?? null}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={100}
|
||||
/>
|
||||
@@ -134,7 +142,7 @@ function FilterValueInput({
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
@@ -144,16 +152,16 @@ function FilterValueInput({
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
filters: ViewFilterConfig[];
|
||||
conditions: FilterCondition[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (filters: ViewFilterConfig[]) => void;
|
||||
onChange: (conditions: FilterCondition[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFilterConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
filters,
|
||||
conditions,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
@@ -169,18 +177,20 @@ export function ViewFilterConfigPopover({
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
const validOperators = getOperatorsForType(firstProperty.type);
|
||||
const defaultOperator = validOperators.includes("contains") ? "contains" : validOperators[0];
|
||||
const defaultOperator = validOperators.includes("contains")
|
||||
? ("contains" as FilterOperator)
|
||||
: validOperators[0];
|
||||
onChange([
|
||||
...filters,
|
||||
{ propertyId: firstProperty.id, operator: defaultOperator },
|
||||
...conditions,
|
||||
{ propertyId: firstProperty.id, op: defaultOperator },
|
||||
]);
|
||||
}, [filters, properties, onChange]);
|
||||
}, [conditions, properties, onChange]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(filters.filter((_, i) => i !== index));
|
||||
onChange(conditions.filter((_, i) => i !== index));
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
@@ -188,15 +198,15 @@ export function ViewFilterConfigPopover({
|
||||
if (!propertyId) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
onChange(
|
||||
filters.map((f, i) => {
|
||||
conditions.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
if (newProperty) {
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(f.operator);
|
||||
const currentOperatorValid = validOperators.includes(f.op);
|
||||
return {
|
||||
...f,
|
||||
propertyId,
|
||||
operator: currentOperatorValid ? f.operator : validOperators[0],
|
||||
op: currentOperatorValid ? f.op : validOperators[0],
|
||||
value: currentOperatorValid ? f.value : undefined,
|
||||
};
|
||||
}
|
||||
@@ -204,38 +214,38 @@ export function ViewFilterConfigPopover({
|
||||
}),
|
||||
);
|
||||
},
|
||||
[filters, properties, onChange],
|
||||
[conditions, properties, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(index: number, operator: string | null) => {
|
||||
if (!operator) return;
|
||||
const op = operator as ViewFilterOperator;
|
||||
const op = operator as FilterOperator;
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(op);
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
conditions.map((f, i) =>
|
||||
i === index
|
||||
? {
|
||||
...f,
|
||||
operator: op,
|
||||
op,
|
||||
value: needsValue ? f.value : undefined,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
conditions.map((f, i) =>
|
||||
i === index ? { ...f, value: value || undefined } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -255,44 +265,46 @@ export function ViewFilterConfigPopover({
|
||||
{t("Filter by")}
|
||||
</Text>
|
||||
|
||||
{filters.length === 0 && (
|
||||
{conditions.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No filters applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{filters.map((filter, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
|
||||
const property = properties.find((p) => p.id === filter.propertyId);
|
||||
{conditions.map((condition, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
|
||||
const property = properties.find(
|
||||
(p) => p.id === condition.propertyId,
|
||||
);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS
|
||||
.filter((op) => validOperators.includes(op.value))
|
||||
.map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={filter.propertyId}
|
||||
value={condition.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={operatorOptions}
|
||||
value={filter.operator}
|
||||
value={condition.op}
|
||||
onChange={(val) => handleOperatorChange(index, val)}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
filter={filter}
|
||||
condition={condition}
|
||||
property={property}
|
||||
onChange={(val) => handleValueChange(index, val)}
|
||||
t={t}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useQueryClient, InfiniteData } from "@tanstack/react-query";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||
import {
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
type BaseRowCreated = {
|
||||
operation: "base:row:created";
|
||||
baseId: string;
|
||||
row: IBaseRow;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowUpdated = {
|
||||
operation: "base:row:updated";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
updatedCells: Record<string, unknown>;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowDeleted = {
|
||||
operation: "base:row:deleted";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowReordered = {
|
||||
operation: "base:row:reordered";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
position: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BasePropertyEvent = {
|
||||
operation:
|
||||
| "base:property:created"
|
||||
| "base:property:updated"
|
||||
| "base:property:deleted"
|
||||
| "base:property:reordered";
|
||||
baseId: string;
|
||||
property?: IBaseProperty;
|
||||
propertyId?: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseViewEvent = {
|
||||
operation:
|
||||
| "base:view:created"
|
||||
| "base:view:updated"
|
||||
| "base:view:deleted";
|
||||
baseId: string;
|
||||
view?: IBaseView;
|
||||
viewId?: string;
|
||||
};
|
||||
|
||||
type BaseSchemaBumped = {
|
||||
operation: "base:schema:bumped";
|
||||
baseId: string;
|
||||
schemaVersion: number;
|
||||
};
|
||||
|
||||
type BaseInboundEvent =
|
||||
| BaseRowCreated
|
||||
| BaseRowUpdated
|
||||
| BaseRowDeleted
|
||||
| BaseRowReordered
|
||||
| BasePropertyEvent
|
||||
| BaseViewEvent
|
||||
| BaseSchemaBumped
|
||||
| { operation: string; baseId: string };
|
||||
|
||||
/*
|
||||
* Module-level set of requestIds we've just sent to the server. When the
|
||||
* socket echoes back the mutation as a `base:row:*` / `base:property:*`
|
||||
* event with a matching `requestId`, the socket handler drops it because
|
||||
* the local mutation already updated the cache. Bounded so it can't grow
|
||||
* unbounded on a long-lived tab.
|
||||
*/
|
||||
const outboundRequestIds = new Set<string>();
|
||||
const OUTBOUND_MAX = 256;
|
||||
|
||||
export function markRequestIdOutbound(requestId: string): void {
|
||||
outboundRequestIds.add(requestId);
|
||||
if (outboundRequestIds.size > OUTBOUND_MAX) {
|
||||
const oldest = outboundRequestIds.values().next().value;
|
||||
if (oldest) outboundRequestIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Realtime bridge for a single base. Joins the server's `base-{baseId}`
|
||||
* room on mount, leaves on unmount, and reconciles the React Query caches
|
||||
* (`["base-rows", baseId, ...]` and `["bases", baseId]`) when events
|
||||
* arrive from other clients.
|
||||
*/
|
||||
export function useBaseSocket(baseId: string | undefined): void {
|
||||
const socket = useAtomValue(socketAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !baseId) return;
|
||||
|
||||
socket.emit("message", { operation: "base:subscribe", baseId });
|
||||
|
||||
const handler = (raw: unknown) => {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const event = raw as BaseInboundEvent;
|
||||
if (event.baseId !== baseId) return;
|
||||
|
||||
const requestId = (event as any).requestId as string | undefined;
|
||||
if (requestId && outboundRequestIds.has(requestId)) {
|
||||
outboundRequestIds.delete(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.operation) {
|
||||
case "base:row:created": {
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", baseId] });
|
||||
break;
|
||||
}
|
||||
case "base:row:updated": {
|
||||
const e = event as BaseRowUpdated;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...e.updatedCells },
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "base:row:deleted": {
|
||||
const e = event as BaseRowDeleted;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== e.rowId),
|
||||
})),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "base:row:reordered": {
|
||||
const e = event as BaseRowReordered;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? { ...row, position: e.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "base:property:created":
|
||||
case "base:property:updated":
|
||||
case "base:property:deleted":
|
||||
case "base:property:reordered":
|
||||
case "base:view:created":
|
||||
case "base:view:updated":
|
||||
case "base:view:deleted": {
|
||||
// Schema/metadata events touch `properties` / `views` on the
|
||||
// base, not the cell data. The row cache only gets invalidated
|
||||
// when a `base:schema:bumped` arrives (i.e. cells actually
|
||||
// migrated) — otherwise a big-base conversion would trigger a
|
||||
// serial refetch of every cached infinite-query page.
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", baseId] });
|
||||
break;
|
||||
}
|
||||
case "base:schema:bumped": {
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", baseId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", baseId] });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("message", handler);
|
||||
|
||||
return () => {
|
||||
socket.off("message", handler);
|
||||
socket.emit("message", { operation: "base:unsubscribe", baseId });
|
||||
};
|
||||
}, [socket, baseId, queryClient]);
|
||||
}
|
||||
@@ -61,7 +61,13 @@ export function useUpdatePropertyMutation() {
|
||||
},
|
||||
);
|
||||
|
||||
if (result.conversionSummary || variables.type) {
|
||||
// Invalidate rows only for the synchronous (inline) path — the
|
||||
// HTTP response there is the "cells are migrated" signal. When the
|
||||
// server hands back a `jobId`, cells are still being rewritten; the
|
||||
// `base:schema:bumped` socket event is the canonical refetch
|
||||
// trigger in that case, and we'd only churn pages with old data by
|
||||
// refetching now.
|
||||
if (variables.type && !result.jobId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
@@ -16,41 +16,61 @@ import {
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
ReorderRowInput,
|
||||
ViewFilterConfig,
|
||||
FilterNode,
|
||||
SearchSpec,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { markRequestIdOutbound } from "@/features/base/hooks/use-base-socket";
|
||||
|
||||
type RowCacheContext = {
|
||||
snapshots: [readonly unknown[], InfiniteData<IPagination<IBaseRow>> | undefined][];
|
||||
};
|
||||
|
||||
// Generate a fresh requestId and pre-register it as outbound so the
|
||||
// incoming socket echo is suppressed by `useBaseSocket`.
|
||||
function newRequestId(): string {
|
||||
const id =
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof (crypto as any).randomUUID === "function"
|
||||
? (crypto as any).randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
markRequestIdOutbound(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function useBaseRowsQuery(
|
||||
baseId: string | undefined,
|
||||
filters?: ViewFilterConfig[],
|
||||
filter?: FilterNode,
|
||||
sorts?: ViewSortConfig[],
|
||||
search?: SearchSpec,
|
||||
) {
|
||||
// Normalize empty arrays to undefined so query keys stay stable
|
||||
const activeFilters = filters?.length ? filters : undefined;
|
||||
const activeFilter = filter ?? undefined;
|
||||
const activeSorts = sorts?.length ? sorts : undefined;
|
||||
const activeSearch = search?.query ? search : undefined;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["base-rows", baseId, activeFilters, activeSorts],
|
||||
queryKey: ["base-rows", baseId, activeFilter, activeSorts, activeSearch],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(baseId!, {
|
||||
cursor: pageParam,
|
||||
limit: 100,
|
||||
filters: activeFilters,
|
||||
filter: activeFilter,
|
||||
sorts: activeSorts,
|
||||
search: activeSearch,
|
||||
}),
|
||||
enabled: !!baseId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Cap cached pages so an invalidate after a type-conversion refetches
|
||||
// a bounded set instead of serially re-requesting every page the user
|
||||
// has ever scrolled through.
|
||||
maxPages: 5,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +84,7 @@ export function flattenRows(
|
||||
export function useCreateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, CreateRowInput>({
|
||||
mutationFn: (data) => createRow(data),
|
||||
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
|
||||
onSuccess: (newRow) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", newRow.baseId] },
|
||||
@@ -95,7 +115,7 @@ export function useCreateRowMutation() {
|
||||
export function useUpdateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => updateRow(data),
|
||||
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
@@ -164,7 +184,7 @@ export function useUpdateRowMutation() {
|
||||
export function useDeleteRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRow(data),
|
||||
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
@@ -207,7 +227,7 @@ export function useDeleteRowMutation() {
|
||||
export function useReorderRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => reorderRow(data),
|
||||
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
UpdatePropertyResult,
|
||||
ViewFilterConfig,
|
||||
FilterNode,
|
||||
SearchSpec,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
@@ -111,8 +112,9 @@ export async function listRows(
|
||||
viewId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
filters?: ViewFilterConfig[];
|
||||
filter?: FilterNode;
|
||||
sorts?: ViewSortConfig[];
|
||||
search?: SearchSpec;
|
||||
},
|
||||
): Promise<IPagination<IBaseRow>> {
|
||||
const req = await api.post("/bases/rows/list", { baseId, ...params });
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.headerConvertingSpinner {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -81,6 +81,11 @@ export type IBaseProperty = {
|
||||
type: BasePropertyType;
|
||||
position: string;
|
||||
typeOptions: TypeOptions;
|
||||
// Set while a background type-conversion job is rewriting cells. The
|
||||
// live `type` stays on the old kind until the job commits, so cells
|
||||
// render correctly; the column header shows a "Converting…" badge.
|
||||
pendingType?: BasePropertyType | null;
|
||||
pendingTypeOptions?: TypeOptions | null;
|
||||
isPrimary: boolean;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
@@ -104,27 +109,49 @@ export type ViewSortConfig = {
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type ViewFilterOperator =
|
||||
| 'equals'
|
||||
| 'notEquals'
|
||||
// Matches the server's engine operator set (core/base/engine/schema.zod.ts).
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'ncontains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'greaterThan'
|
||||
| 'lessThan'
|
||||
| 'before'
|
||||
| 'after';
|
||||
| 'after'
|
||||
| 'onOrBefore'
|
||||
| 'onOrAfter'
|
||||
| 'any'
|
||||
| 'none'
|
||||
| 'all';
|
||||
|
||||
export type ViewFilterConfig = {
|
||||
export type FilterCondition = {
|
||||
propertyId: string;
|
||||
operator: ViewFilterOperator;
|
||||
op: FilterOperator;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export type FilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: Array<FilterCondition | FilterGroup>;
|
||||
};
|
||||
|
||||
export type FilterNode = FilterCondition | FilterGroup;
|
||||
|
||||
export type SearchSpec = {
|
||||
query: string;
|
||||
mode?: 'trgm' | 'fts';
|
||||
};
|
||||
|
||||
export type ViewConfig = {
|
||||
sorts?: ViewSortConfig[];
|
||||
filters?: ViewFilterConfig[];
|
||||
filter?: FilterGroup;
|
||||
visiblePropertyIds?: string[];
|
||||
hiddenPropertyIds?: string[];
|
||||
propertyWidths?: Record<string, number>;
|
||||
@@ -183,6 +210,7 @@ export type CreatePropertyInput = {
|
||||
name: string;
|
||||
type: BasePropertyType;
|
||||
typeOptions?: TypeOptions;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type UpdatePropertyInput = {
|
||||
@@ -191,40 +219,47 @@ export type UpdatePropertyInput = {
|
||||
name?: string;
|
||||
type?: BasePropertyType;
|
||||
typeOptions?: TypeOptions;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type DeletePropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ReorderPropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
position: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type CreateRowInput = {
|
||||
baseId: string;
|
||||
cells?: Record<string, unknown>;
|
||||
afterRowId?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type UpdateRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
cells: Record<string, unknown>;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type DeleteRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ReorderRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
position: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type CreateViewInput = {
|
||||
@@ -247,13 +282,11 @@ export type DeleteViewInput = {
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export type ConversionSummary = {
|
||||
converted: number;
|
||||
cleared: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type UpdatePropertyResult = {
|
||||
property: IBaseProperty;
|
||||
conversionSummary: ConversionSummary | null;
|
||||
// Non-null when the property change kicked off a BullMQ type-conversion
|
||||
// job (select/multiSelect/person/file → anything, or any → system type).
|
||||
// Client can listen for `base:schema:bumped` on the base room to know
|
||||
// when the job finished migrating cells.
|
||||
jobId: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user