mirror of
https://github.com/docmost/docmost.git
synced 2026-07-25 00:53:18 +10:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { expandPagesBatched } from "./page-expand-loader";
|
||||
|
||||
export type ResolvedPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
};
|
||||
|
||||
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
const map = await expandPagesBatched(pageIds);
|
||||
const out: ResolvedPage[] = [];
|
||||
for (const id of pageIds) {
|
||||
const p = map.get(id);
|
||||
if (p) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Stable, sorted, deduped list so the query key is consistent regardless of caller ordering.
|
||||
function normalize(ids: (string | null | undefined)[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const id of ids) {
|
||||
if (typeof id === "string" && id.length > 0) set.add(id);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
export type PageResolution = {
|
||||
// Map lookup states: key absent = not requested, undefined = resolving, null = inaccessible, ResolvedPage = accessible.
|
||||
pages: Map<string, ResolvedPage | null | undefined>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useResolvedPages(
|
||||
pageIds: (string | null | undefined)[],
|
||||
): PageResolution {
|
||||
const normalized = useMemo(() => normalize(pageIds), [pageIds]);
|
||||
|
||||
const { data, isSuccess, isLoading } = useQuery({
|
||||
queryKey: ["bases", "pages", "expand", normalized],
|
||||
queryFn: () => resolvePages(normalized),
|
||||
enabled: normalized.length > 0,
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const map = new Map<string, ResolvedPage | null | undefined>();
|
||||
for (const id of normalized) map.set(id, isSuccess ? null : undefined);
|
||||
for (const item of data ?? []) map.set(item.id, item);
|
||||
return map;
|
||||
}, [normalized, data, isSuccess]);
|
||||
|
||||
return { pages, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { InfiniteData, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createProperty,
|
||||
updateProperty,
|
||||
deleteProperty,
|
||||
reorderProperty,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
CreatePropertyInput,
|
||||
UpdatePropertyInput,
|
||||
DeletePropertyInput,
|
||||
ReorderPropertyInput,
|
||||
UpdatePropertyResult,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export function useCreatePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseProperty, Error, CreatePropertyInput>({
|
||||
mutationFn: (data) => createProperty(data),
|
||||
onSuccess: (newProperty) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", newProperty.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: [...old.properties, newProperty],
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<UpdatePropertyResult, Error, UpdatePropertyInput>({
|
||||
mutationFn: (data) => updateProperty(data),
|
||||
onSuccess: (result, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.map((p) =>
|
||||
p.id === result.property.id ? result.property : p,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (variables.type && !result.jobId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to update property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeletePropertyInput>({
|
||||
mutationFn: (data) => deleteProperty(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.filter(
|
||||
(p) => p.id !== variables.propertyId,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) => {
|
||||
if (!(variables.propertyId in row.cells)) return row;
|
||||
const { [variables.propertyId]: _, ...rest } = row.cells;
|
||||
return { ...row, cells: rest };
|
||||
}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderPropertyInput, { previous: IBase | undefined }>({
|
||||
mutationFn: (data) => reorderProperty(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["bases", variables.pageId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<IBase>([
|
||||
"bases",
|
||||
variables.pageId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.map((p) =>
|
||||
p.id === variables.propertyId
|
||||
? { ...p, position: variables.position }
|
||||
: p,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["bases", variables.pageId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to reorder property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createBase,
|
||||
getBaseInfo,
|
||||
updateBase,
|
||||
deleteBase,
|
||||
convertPageToBase,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
CreateBaseInput,
|
||||
UpdateBaseInput,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||
|
||||
export function useBaseQuery(
|
||||
pageId: string | undefined,
|
||||
): UseQueryResult<IBase, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["bases", pageId],
|
||||
queryFn: () => getBaseInfo(pageId!),
|
||||
enabled: !!pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBase, Error, CreateBaseInput>({
|
||||
mutationFn: (data) => createBase(data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["bases", "list", data.spaceId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConvertPageToBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
|
||||
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
|
||||
onSuccess: (base) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["root-sidebar-pages", base.spaceId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["sidebar-pages"] });
|
||||
setTreeData((prev) =>
|
||||
treeModel.update(prev, base.id, { isBase: true } as Partial<SpaceTreeNode>),
|
||||
);
|
||||
socket?.emit("message", {
|
||||
operation: "updateOne",
|
||||
spaceId: base.spaceId,
|
||||
entity: ["pages"],
|
||||
id: base.id,
|
||||
payload: { isBase: true },
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBase, Error, UpdateBaseInput>({
|
||||
mutationFn: (data) => updateBase(data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<IBase>(["bases", data.id], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, ...data };
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to update base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, { pageId: string; spaceId: string }>({
|
||||
mutationFn: ({ pageId }) => deleteBase(pageId),
|
||||
onSuccess: (_, { pageId, spaceId }) => {
|
||||
queryClient.removeQueries({ queryKey: ["bases", pageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["bases", "list", spaceId],
|
||||
});
|
||||
notifications.show({ message: t("Base deleted") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
deleteRows,
|
||||
getRowInfo,
|
||||
listRows,
|
||||
reorderRow,
|
||||
IBaseRowsPage,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
DeleteRowsInput,
|
||||
ReorderRowInput,
|
||||
FilterNode,
|
||||
ViewSortConfig,
|
||||
RowReferences,
|
||||
NO_VALUE_CHOICE_ID,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
|
||||
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
|
||||
type RowCacheContext = {
|
||||
snapshots: [readonly unknown[], InfiniteData<IBaseRowsPage> | undefined][];
|
||||
};
|
||||
|
||||
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
|
||||
// Strip it at the query boundary to keep request payloads clean and cache keys stable.
|
||||
export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
|
||||
if (!filter) return undefined;
|
||||
if ('children' in filter && filter.children.length === 0) return undefined;
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Pre-register the requestId as outbound so the socket echo is suppressed by useBaseSocket.
|
||||
function newRequestId(): string {
|
||||
const id = uuid7();
|
||||
markRequestIdOutbound(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function baseRowsQueryKey(
|
||||
pageId: string | undefined,
|
||||
filter: FilterNode | undefined,
|
||||
sorts: ViewSortConfig[] | undefined,
|
||||
) {
|
||||
return [
|
||||
"base-rows",
|
||||
pageId,
|
||||
normalizeFilter(filter),
|
||||
sorts?.length ? sorts : undefined,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export function findRowInInfinite(
|
||||
data: InfiniteData<IBaseRowsPage> | undefined,
|
||||
rowId: string,
|
||||
): IBaseRow | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const page of data.pages) {
|
||||
const row = page.items.find((r) => r.id === rowId);
|
||||
if (row) return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useBaseRowsQuery(
|
||||
pageId: string | undefined,
|
||||
filter?: FilterNode,
|
||||
sorts?: ViewSortConfig[],
|
||||
) {
|
||||
const activeFilter = normalizeFilter(filter);
|
||||
const activeSorts = sorts?.length ? sorts : undefined;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: baseRowsQueryKey(pageId, filter, sorts),
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(pageId!, {
|
||||
cursor: pageParam,
|
||||
limit: 100,
|
||||
filter: activeFilter,
|
||||
sorts: activeSorts,
|
||||
}),
|
||||
enabled: !!pageId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IBaseRowsPage) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const refPages = useMemo(
|
||||
() =>
|
||||
(query.data?.pages ?? [])
|
||||
.map((p) => p.references)
|
||||
.filter(Boolean) as RowReferences[],
|
||||
[query.data],
|
||||
);
|
||||
useHydrateReferences(pageId, refPages);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function flattenRows(
|
||||
data: InfiniteData<IBaseRowsPage> | undefined,
|
||||
): IBaseRow[] {
|
||||
if (!data) return [];
|
||||
return data.pages.flatMap((page) => page.items);
|
||||
}
|
||||
|
||||
export function useCreateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, CreateRowInput>({
|
||||
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
|
||||
onSuccess: (newRow) => {
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", newRow.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === lastPageIndex) {
|
||||
return { ...page, items: [...page.items, newRow] };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
|
||||
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
|
||||
invalidateBaseRows(newRow.pageId);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Single row by id — for deep links (?row=) pointing outside the loaded
|
||||
* pages or the active view's filter. No retry: an error means the row is
|
||||
* gone and the caller should close. */
|
||||
export function useBaseRowQuery(
|
||||
pageId: string | undefined,
|
||||
rowId: string | undefined,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery<IBaseRow, Error>({
|
||||
queryKey: ["base-row", pageId, rowId],
|
||||
queryFn: () => getRowInfo(rowId!, pageId!),
|
||||
enabled: !!pageId && !!rowId && (options?.enabled ?? true),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...variables.cells },
|
||||
...(variables.position !== undefined && {
|
||||
position: variables.position,
|
||||
}),
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<IBaseRow>(
|
||||
["base-row", variables.pageId, variables.rowId],
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, cells: { ...old.cells, ...variables.cells } }
|
||||
: old,
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["base-row", variables.pageId, variables.rowId],
|
||||
});
|
||||
notifications.show({
|
||||
message: t("Failed to update row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedRow, variables) => {
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", updatedRow.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === updatedRow.id
|
||||
? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData<IBaseRow>(
|
||||
["base-row", updatedRow.pageId, updatedRow.id],
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
|
||||
: old,
|
||||
);
|
||||
|
||||
const base = queryClient.getQueryData<IBase>(["bases", variables.pageId]);
|
||||
const kanbanGroupByIds = new Set(
|
||||
(base?.views ?? [])
|
||||
.filter((v) => v.type === "kanban")
|
||||
.map((v) => v.config?.groupByPropertyId)
|
||||
.filter(Boolean) as string[],
|
||||
);
|
||||
const changedPropertyIds = Object.keys(variables.cells ?? {});
|
||||
if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
|
||||
invalidateBaseRows(variables.pageId);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== variables.rowId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to delete row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowsMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
const removeSet = new Set(variables.rowIds);
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => !removeSet.has(row.id)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to delete rows"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function useReorderRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? { ...row, position: variables.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to reorder row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type KanbanMoveCardInput = {
|
||||
pageId: string;
|
||||
rowId: string;
|
||||
sourceColumnFilter: FilterNode | undefined;
|
||||
destColumnFilter: FilterNode | undefined;
|
||||
columnChanged: boolean;
|
||||
groupByPropertyId: string;
|
||||
destChoiceValue: string | null;
|
||||
position: string;
|
||||
};
|
||||
|
||||
type KanbanMoveCardContext = {
|
||||
snapshots: [readonly unknown[], unknown][];
|
||||
};
|
||||
|
||||
export function useKanbanMoveCardMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, KanbanMoveCardInput, KanbanMoveCardContext>({
|
||||
mutationFn: ({ pageId, rowId, columnChanged, groupByPropertyId, destChoiceValue, position }) =>
|
||||
updateRow({
|
||||
pageId,
|
||||
rowId,
|
||||
cells: columnChanged ? { [groupByPropertyId]: destChoiceValue } : {},
|
||||
position,
|
||||
requestId: newRequestId(),
|
||||
}),
|
||||
onMutate: async (variables) => {
|
||||
const { pageId, rowId, sourceColumnFilter, destColumnFilter, columnChanged, groupByPropertyId, destChoiceValue, position } = variables;
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: ["base-rows", pageId] });
|
||||
|
||||
const sourceKey = baseRowsQueryKey(pageId, sourceColumnFilter, undefined);
|
||||
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
|
||||
|
||||
const sourceSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(sourceKey);
|
||||
const destSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(destKey);
|
||||
const snapshots: KanbanMoveCardContext["snapshots"] = [
|
||||
[sourceKey, sourceSnapshot],
|
||||
[destKey, destSnapshot],
|
||||
];
|
||||
|
||||
if (columnChanged) {
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(sourceKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((r) => r.id !== rowId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const movingRow = findRowInInfinite(sourceSnapshot, rowId);
|
||||
if (movingRow) {
|
||||
const moved: IBaseRow = {
|
||||
...movingRow,
|
||||
cells: { ...movingRow.cells, [groupByPropertyId]: destChoiceValue },
|
||||
position,
|
||||
};
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) =>
|
||||
index === lastPageIndex
|
||||
? { ...page, items: [...page.items, moved] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((r) =>
|
||||
r.id === rowId ? { ...r, position } : r,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to move card"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type KanbanCreateCardInput = {
|
||||
pageId: string;
|
||||
destColumnFilter: FilterNode | undefined;
|
||||
groupByPropertyId: string;
|
||||
columnKey: string;
|
||||
position?: string;
|
||||
};
|
||||
|
||||
export function useKanbanCreateCardMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, KanbanCreateCardInput>({
|
||||
mutationFn: ({ pageId, groupByPropertyId, columnKey, position }) =>
|
||||
createRow({
|
||||
pageId,
|
||||
cells: columnKey === NO_VALUE_CHOICE_ID ? {} : { [groupByPropertyId]: columnKey },
|
||||
position,
|
||||
requestId: newRequestId(),
|
||||
}),
|
||||
onSuccess: (newRow, variables) => {
|
||||
const { pageId, destColumnFilter } = variables;
|
||||
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) =>
|
||||
index === lastPageIndex
|
||||
? { ...page, items: [...page.items, newRow] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to add card"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateBaseRows(pageId: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createView,
|
||||
updateView,
|
||||
deleteView,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseView,
|
||||
CreateViewInput,
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
ViewConfig,
|
||||
ViewConfigPatch,
|
||||
} from "@/ee/base/types/base.types";
|
||||
|
||||
function applyConfigPatch(
|
||||
existing: ViewConfig | undefined,
|
||||
patch: ViewConfigPatch | undefined,
|
||||
): ViewConfig {
|
||||
const merged: Record<string, unknown> = { ...(existing ?? {}) };
|
||||
for (const [key, value] of Object.entries(patch ?? {})) {
|
||||
if (value === null) delete merged[key];
|
||||
else if (value !== undefined) merged[key] = value;
|
||||
}
|
||||
return merged as ViewConfig;
|
||||
}
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCreateViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseView, Error, CreateViewInput>({
|
||||
mutationFn: (data) => createView(data),
|
||||
onSuccess: (newView) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", newView.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: [...old.views, newView],
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseView, Error, UpdateViewInput, { previous: IBase | undefined }>({
|
||||
mutationFn: (data) => updateView(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["bases", variables.pageId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<IBase>([
|
||||
"bases",
|
||||
variables.pageId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.map((v) =>
|
||||
v.id === variables.viewId
|
||||
? {
|
||||
...v,
|
||||
...(variables.name !== undefined && {
|
||||
name: variables.name,
|
||||
}),
|
||||
...(variables.type !== undefined && {
|
||||
type: variables.type,
|
||||
}),
|
||||
...(variables.config !== undefined && {
|
||||
config: applyConfigPatch(v.config, variables.config),
|
||||
}),
|
||||
...(variables.position !== undefined && {
|
||||
position: variables.position,
|
||||
}),
|
||||
}
|
||||
: v,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["bases", variables.pageId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to update view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedView) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", updatedView.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.map((v) =>
|
||||
v.id === updatedView.id ? updatedView : v,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteViewInput>({
|
||||
mutationFn: (data) => deleteView(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.pageId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.filter((v) => v.id !== variables.viewId),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import api from "@/lib/api-client";
|
||||
import type { ResolvedPage } from "./base-page-resolver-query";
|
||||
|
||||
type Waiter = {
|
||||
requestedIds: readonly string[];
|
||||
resolve: (m: Map<string, ResolvedPage>) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
type PendingBatch = {
|
||||
ids: Set<string>;
|
||||
waiters: Waiter[];
|
||||
};
|
||||
|
||||
let pending: PendingBatch | null = null;
|
||||
|
||||
export function expandPagesBatched(
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, ResolvedPage>> {
|
||||
if (ids.length === 0) return Promise.resolve(new Map());
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pending) {
|
||||
pending = { ids: new Set(), waiters: [] };
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
for (const id of ids) pending.ids.add(id);
|
||||
pending.waiters.push({ requestedIds: ids, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
const batch = pending;
|
||||
pending = null;
|
||||
if (!batch) return;
|
||||
|
||||
const unionIds = Array.from(batch.ids);
|
||||
try {
|
||||
const res = await api.post<{ items: ResolvedPage[] }>(
|
||||
"/bases/pages/expand",
|
||||
{ pageIds: unionIds },
|
||||
);
|
||||
const byId = new Map<string, ResolvedPage>();
|
||||
for (const item of res.data.items) byId.set(item.id, item);
|
||||
|
||||
for (const w of batch.waiters) {
|
||||
const subset = new Map<string, ResolvedPage>();
|
||||
for (const id of w.requestedIds) {
|
||||
const page = byId.get(id);
|
||||
if (page) subset.set(id, page);
|
||||
}
|
||||
w.resolve(subset);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const w of batch.waiters) w.reject(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user