diff --git a/.changeset/calm-filters-arrive.md b/.changeset/calm-filters-arrive.md new file mode 100644 index 0000000000..3c52af8603 --- /dev/null +++ b/.changeset/calm-filters-arrive.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose atomic user-scoped setting mutations for ordered personal preferences. diff --git a/packages/core/src/settings/index.ts b/packages/core/src/settings/index.ts index 50d86697be..f6d7b00826 100644 --- a/packages/core/src/settings/index.ts +++ b/packages/core/src/settings/index.ts @@ -22,6 +22,7 @@ export { readSetting, writeSetting, removeSetting } from "./script-helpers.js"; export { getUserSetting, putUserSetting, + mutateUserSetting, deleteUserSetting, } from "./user-settings.js"; diff --git a/packages/core/src/settings/user-settings.spec.ts b/packages/core/src/settings/user-settings.spec.ts index cda62b0bcc..30b8c7824a 100644 --- a/packages/core/src/settings/user-settings.spec.ts +++ b/packages/core/src/settings/user-settings.spec.ts @@ -4,15 +4,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockGetSetting = vi.fn(); const mockPutSetting = vi.fn(); const mockDeleteSetting = vi.fn(); +const mockMutateSetting = vi.fn(); vi.mock("./store.js", () => ({ getSetting: (...args: any[]) => mockGetSetting(...args), putSetting: (...args: any[]) => mockPutSetting(...args), deleteSetting: (...args: any[]) => mockDeleteSetting(...args), + mutateSetting: (...args: any[]) => mockMutateSetting(...args), })); import { getUserSetting, + mutateUserSetting, putUserSetting, deleteUserSetting, } from "./user-settings.js"; @@ -80,6 +83,29 @@ describe("user-settings", () => { }); }); + describe("mutateUserSetting", () => { + it("atomically mutates only the prefixed user key", async () => { + const updater = vi.fn((current) => ({ + count: Number(current?.count ?? 0) + 1, + })); + mockMutateSetting.mockResolvedValue({ count: 2 }); + + const result = await mutateUserSetting( + "alice@test.com", + "counter", + updater, + { requestSource: "tab-1" }, + ); + + expect(mockMutateSetting).toHaveBeenCalledWith( + "u:alice@test.com:counter", + updater, + { requestSource: "tab-1" }, + ); + expect(result).toEqual({ count: 2 }); + }); + }); + describe("deleteUserSetting", () => { it("prefixes key with u::", async () => { mockDeleteSetting.mockResolvedValue(true); diff --git a/packages/core/src/settings/user-settings.ts b/packages/core/src/settings/user-settings.ts index 48c9ff7312..086c536512 100644 --- a/packages/core/src/settings/user-settings.ts +++ b/packages/core/src/settings/user-settings.ts @@ -12,6 +12,7 @@ import { getSetting, putSetting, deleteSetting, + mutateSetting, type StoreWriteOptions, } from "./store.js"; @@ -37,6 +38,18 @@ export async function putUserSetting( return putSetting(userKey(email, key), value, options); } +/** Atomically derive and persist one user-scoped setting. */ +export async function mutateUserSetting( + email: string, + key: string, + updater: ( + current: Record | null, + ) => Record | Promise>, + options?: StoreWriteOptions, +): Promise> { + return mutateSetting(userKey(email, key), updater, options); +} + /** Delete a user-scoped setting. */ export async function deleteUserSetting( email: string, diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index f825b31719..44cbacfb31 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -64,6 +64,51 @@ const legacyPersonalViewOverridesSchema = z.object({ ...personalViewOverridesFields, }); +const storedPersonalViewStateSchema = z.object({ + storageVersion: z.literal(1), + overrides: personalViewOverridesSchema.nullable(), + mutationSequences: z.record(z.string(), z.number().int().nonnegative()), +}); + +export function normalizeStoredPersonalDatabaseViewState( + stored: Record | null, +) { + const state = storedPersonalViewStateSchema.safeParse(stored); + if (state.success) return state.data; + const overrides = personalViewOverridesSchema.safeParse(stored); + return { + storageVersion: 1 as const, + overrides: overrides.success ? overrides.data : null, + mutationSequences: {} as Record, + }; +} + +export function orderedPersonalDatabaseViewState(args: { + current: Record | null; + mutationSource: string; + mutationSequence: number; + overrides: z.infer | null; +}) { + const current = normalizeStoredPersonalDatabaseViewState(args.current); + if ( + args.mutationSequence <= + (current.mutationSequences[args.mutationSource] ?? -1) + ) { + return current; + } + const recentSources = Object.entries(current.mutationSequences) + .filter(([source]) => source !== args.mutationSource) + .slice(-15); + return { + storageVersion: 1 as const, + overrides: args.overrides, + mutationSequences: Object.fromEntries([ + ...recentSources, + [args.mutationSource, args.mutationSequence], + ]), + }; +} + export async function assertContentDatabaseViewerAccess(databaseId: string) { const db = getDb(); const [database] = await db @@ -93,10 +138,14 @@ export async function readPersonalDatabaseViewOverrides( userEmail, personalDatabaseViewSettingKey(databaseId), ); - const parsed = personalViewOverridesSchema.safeParse(stored); + const storedState = storedPersonalViewStateSchema.safeParse(stored); + const storedOverrides = storedState.success + ? storedState.data.overrides + : stored; + const parsed = personalViewOverridesSchema.safeParse(storedOverrides); if (parsed.success) return parsed.data; - const legacy = legacyPersonalViewOverridesSchema.safeParse(stored); + const legacy = legacyPersonalViewOverridesSchema.safeParse(storedOverrides); if (!legacy.success) return null; const [database] = await getDb() .select({ systemRole: schema.contentDatabases.systemRole }) diff --git a/templates/content/actions/update-content-database-personal-view.test.ts b/templates/content/actions/update-content-database-personal-view.test.ts index f6a316b8ca..309c6c1926 100644 --- a/templates/content/actions/update-content-database-personal-view.test.ts +++ b/templates/content/actions/update-content-database-personal-view.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION } from "./_content-database-personal-view"; +import { + PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + normalizeStoredPersonalDatabaseViewState, + orderedPersonalDatabaseViewState, +} from "./_content-database-personal-view"; import action from "./update-content-database-personal-view"; describe("update content database personal view", () => { @@ -44,4 +48,75 @@ describe("update content database personal view", () => { }).overrides, ).toBeNull(); }); + + it("requires mutation ordering fields together", () => { + expect(() => + action.schema.parse({ + databaseId: "database", + overrides: null, + mutationSource: "tab-1", + }), + ).toThrow(/mutationSource and mutationSequence/); + }); + + it("keeps the newest override when requests arrive out of order", () => { + const first = orderedPersonalDatabaseViewState({ + current: null, + mutationSource: "tab-1", + mutationSequence: 2, + overrides: personalOverrides("newest"), + }); + const stale = orderedPersonalDatabaseViewState({ + current: first, + mutationSource: "tab-1", + mutationSequence: 1, + overrides: personalOverrides("stale"), + }); + + expect( + normalizeStoredPersonalDatabaseViewState(stale).overrides?.views[0] + ?.filters[0]?.value, + ).toBe("newest"); + }); + + it("keeps an ordered clear as a tombstone against a stale write", () => { + const cleared = orderedPersonalDatabaseViewState({ + current: null, + mutationSource: "tab-1", + mutationSequence: 2, + overrides: null, + }); + const stale = orderedPersonalDatabaseViewState({ + current: cleared, + mutationSource: "tab-1", + mutationSequence: 1, + overrides: personalOverrides("stale"), + }); + + expect( + normalizeStoredPersonalDatabaseViewState(stale).overrides, + ).toBeNull(); + }); }); + +function personalOverrides(value: string) { + return { + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + activeViewId: "table", + views: [ + { + id: "table", + sorts: [], + filters: [ + { + key: "name", + label: "Name", + operator: "contains" as const, + value, + }, + ], + filterMode: "and" as const, + }, + ], + }; +} diff --git a/templates/content/actions/update-content-database-personal-view.ts b/templates/content/actions/update-content-database-personal-view.ts index 2ea3ec8eea..f1b8aca2df 100644 --- a/templates/content/actions/update-content-database-personal-view.ts +++ b/templates/content/actions/update-content-database-personal-view.ts @@ -1,9 +1,11 @@ import { defineAction } from "@agent-native/core"; -import { deleteUserSetting, putUserSetting } from "@agent-native/core/settings"; +import { mutateUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; import { assertContentDatabaseViewerAccess, + normalizeStoredPersonalDatabaseViewState, + orderedPersonalDatabaseViewState, personalDatabaseViewSettingKey, personalViewOverridesSchema, } from "./_content-database-personal-view.js"; @@ -11,20 +13,51 @@ import { export default defineAction({ description: "Update or clear the current user's personal saved filter, sort, and active view overrides for a content database.", - schema: z.object({ - databaseId: z.string().describe("Database ID"), - overrides: personalViewOverridesSchema.nullable(), - }), - run: async ({ databaseId, overrides }, ctx) => { + schema: z + .object({ + databaseId: z.string().describe("Database ID"), + overrides: personalViewOverridesSchema.nullable(), + mutationSource: z.string().min(1).max(200).optional(), + mutationSequence: z.number().int().nonnegative().optional(), + }) + .refine( + ({ mutationSource, mutationSequence }) => + (mutationSource == null) === (mutationSequence == null), + { + message: + "mutationSource and mutationSequence must be provided together.", + }, + ), + run: async ( + { databaseId, overrides, mutationSource, mutationSequence }, + ctx, + ) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); await assertContentDatabaseViewerAccess(databaseId); const key = personalDatabaseViewSettingKey(databaseId); - if (overrides) { - await putUserSetting(ctx.userEmail, key, overrides); - } else { - await deleteUserSetting(ctx.userEmail, key); + if (mutationSource != null && mutationSequence != null) { + const stored = await mutateUserSetting( + ctx.userEmail, + key, + (current) => + orderedPersonalDatabaseViewState({ + current, + mutationSource, + mutationSequence, + overrides, + }), + { requestSource: mutationSource }, + ); + return { + databaseId, + overrides: normalizeStoredPersonalDatabaseViewState(stored).overrides, + }; } + await mutateUserSetting(ctx.userEmail, key, (current) => ({ + ...normalizeStoredPersonalDatabaseViewState(current), + overrides, + })); return { databaseId, overrides }; }, diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index d013c5966b..360e378a89 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -1,7 +1,9 @@ import type { BuilderCmsModelSummary, + ContentDatabasePersonalViewResponse, ContentDatabaseResponse, } from "@shared/api"; +import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "@shared/api"; // @vitest-environment happy-dom // // Mount the real DatabaseView with an empty mocked database so UI regressions @@ -16,6 +18,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const toastErrorMock = vi.hoisted(() => vi.fn()); const toastSuccessMock = vi.hoisted(() => vi.fn()); const contentDatabaseQueryMock = vi.hoisted(() => vi.fn()); +const keepaliveActionMock = vi.hoisted(() => vi.fn()); +const personalViewQuery = vi.hoisted<{ + data: ContentDatabasePersonalViewResponse | undefined; + isLoading: boolean; +}>(() => ({ data: undefined, isLoading: false })); +const personalViewMutation = vi.hoisted(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, +})); +const updateViewMutation = vi.hoisted(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, +})); + +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + tryCallActionKeepalive: keepaliveActionMock, + }; +}); vi.mock("sonner", async (importOriginal) => { const actual = await importOriginal(); @@ -135,9 +161,9 @@ vi.mock("@/hooks/use-content-database", () => ({ useExecuteBuilderSourceExecution: () => benignMutation, useCancelPreparedBuilderSourceUpdate: () => benignMutation, useSetContentDatabaseSourceWriteMode: () => benignMutation, - useContentDatabasePersonalView: () => ({ data: undefined, isLoading: false }), - useUpdateContentDatabasePersonalView: () => benignMutation, - useUpdateContentDatabaseView: () => benignMutation, + useContentDatabasePersonalView: () => personalViewQuery, + useUpdateContentDatabasePersonalView: () => personalViewMutation, + useUpdateContentDatabaseView: () => updateViewMutation, useDeleteDatabaseItems: () => benignMutation, useDuplicateDatabaseItems: () => benignMutation, useMoveDatabaseItem: () => benignMutation, @@ -229,6 +255,20 @@ describe("DatabaseView UI regressions", () => { toastErrorMock.mockReset(); toastSuccessMock.mockReset(); contentDatabaseQueryMock.mockReset(); + keepaliveActionMock.mockReset(); + keepaliveActionMock.mockReturnValue({ + accepted: true, + bodyBytes: 1, + completion: Promise.resolve({ + databaseId: "database-1", + overrides: null, + }), + }); + personalViewQuery.data = undefined; + personalViewQuery.isLoading = false; + personalViewMutation.isPending = false; + personalViewMutation.mutate.mockReset(); + updateViewMutation.mutate.mockReset(); addItemMutation.mutateAsync.mockReset(); attachSourceMutation.mutateAsync.mockReset(); databasePagination.totalItems = 0; @@ -257,6 +297,7 @@ describe("DatabaseView UI regressions", () => { afterEach(() => { act(() => root.unmount()); + vi.useRealTimers(); container.remove(); vi.unstubAllGlobals(); ( @@ -310,15 +351,20 @@ describe("DatabaseView UI regressions", () => { expect(sortButton?.getAttribute("aria-expanded")).toBe("true"); expect(document.querySelector("[role=menu]")).toBeTruthy(); + const sortPickerInput = + document.querySelector("[role=menu] input"); + expect(sortPickerInput).toBeTruthy(); await act(async () => { - document.activeElement?.dispatchEvent( + sortPickerInput?.focus(); + sortPickerInput?.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), ); await Promise.resolve(); }); expect(sortButton?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(sortButton); await act(async () => { filterButton?.dispatchEvent( @@ -329,6 +375,261 @@ describe("DatabaseView UI regressions", () => { expect(filterButton?.getAttribute("aria-expanded")).toBe("true"); expect(document.querySelector("[role=menu]")).toBeTruthy(); + + const filterPickerInput = + document.querySelector("[role=menu] input"); + expect(filterPickerInput).toBeTruthy(); + await act(async () => { + filterPickerInput?.focus(); + filterPickerInput?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), + ); + await Promise.resolve(); + }); + + expect(filterButton?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(filterButton); + }); + + it("does not expose shared database state before personal overrides hydrate", async () => { + personalViewQuery.isLoading = true; + await renderDatabaseView(); + + expect(container.querySelector('button[aria-label="Filter"]')).toBeNull(); + + personalViewQuery.data = { + databaseId: "database-1", + overrides: { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: databaseViewConfig.activeViewId, + views: databaseViewConfig.views.map((view) => ({ + id: view.id, + sorts: [], + filters: + view.id === databaseViewConfig.activeViewId + ? [ + { + key: "name", + label: "Name", + operator: "contains" as const, + value: "Personal only", + }, + ] + : [], + filterMode: "and" as const, + })), + }, + }; + personalViewQuery.isLoading = false; + await renderDatabaseView(); + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('button[aria-label="1 active filters"]'), + ).toBeTruthy(); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + expect(updateViewMutation.mutate).not.toHaveBeenCalled(); + }); + + it("opens both toolbar menus with Enter, Space, and ArrowDown", async () => { + await renderDatabaseView(); + + for (const label of ["Sort", "Filter"]) { + for (const key of ["Enter", " ", "ArrowDown"]) { + const button = container.querySelector( + `button[aria-label="${label}"]`, + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.focus(); + button?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key }), + ); + await Promise.resolve(); + }); + + expect(button?.getAttribute("aria-expanded")).toBe("true"); + const pickerInput = + document.querySelector("[role=menu] input"); + expect(pickerInput).toBeTruthy(); + await act(async () => { + pickerInput?.focus(); + pickerInput?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), + ); + await Promise.resolve(); + }); + + expect(button?.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(button); + } + } + }); + + it("persists exactly one personal override after filter selection", async () => { + vi.useFakeTimers(); + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + await Promise.resolve(); + }); + await renderDatabaseView(); + const filterValueInput = document.querySelector( + 'input[placeholder="Value"]', + ); + expect(filterValueInput).toBeTruthy(); + await act(async () => { + if (!filterValueInput) return; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set?.call(filterValueInput, "Personal only"); + filterValueInput.dispatchEvent(new Event("input", { bubbles: true })); + await Promise.resolve(); + }); + + personalViewQuery.data = { + databaseId: "database-1", + overrides: { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: databaseViewConfig.activeViewId, + views: databaseViewConfig.views.map((view) => ({ + id: view.id, + sorts: [], + filters: [], + filterMode: "and" as const, + })), + }, + }; + await renderDatabaseView(); + + expect( + container.querySelector('button[aria-label="1 active filters"]'), + ).toBeTruthy(); + + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).toHaveBeenCalledTimes(1); + expect(personalViewMutation.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + databaseId: "database-1", + overrides: expect.objectContaining({ + activeViewId: databaseViewConfig.activeViewId, + views: expect.arrayContaining([ + expect.objectContaining({ + id: databaseViewConfig.activeViewId, + filters: [ + expect.objectContaining({ + key: "name", + value: "Personal only", + }), + ], + }), + ]), + }), + }), + expect.any(Object), + ); + expect(keepaliveActionMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("protects a pending personal override during reload with one keepalive", async () => { + vi.useFakeTimers(); + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + await Promise.resolve(); + }); + window.dispatchEvent(new Event("pagehide")); + + expect(keepaliveActionMock).toHaveBeenCalledTimes(1); + expect(keepaliveActionMock).toHaveBeenCalledWith( + "update-content-database-personal-view", + expect.objectContaining({ databaseId: "database-1" }), + ); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("serializes a newer personal override behind an active save", async () => { + vi.useFakeTimers(); + personalViewMutation.isPending = true; + await renderDatabaseView(); + const filterButton = container.querySelector( + 'button[aria-label="Filter"]', + ); + + await act(async () => { + filterButton?.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + await Promise.resolve(); + }); + const nameItem = [ + ...document.querySelectorAll("[role=menuitem]"), + ].find((item) => item.textContent?.trim() === "Name"); + expect(nameItem).toBeTruthy(); + + await act(async () => { + nameItem?.click(); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect(personalViewMutation.mutate).not.toHaveBeenCalled(); + + personalViewMutation.isPending = false; + await renderDatabaseView(); + await act(async () => { + vi.advanceTimersByTime(50); + await Promise.resolve(); + }); + + expect(personalViewMutation.mutate).toHaveBeenCalledTimes(1); + vi.useRealTimers(); }); it("shows a toast and does not create a row when addItem.mutateAsync rejects", async () => { diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4e9e7a0e91..91e0b459ca 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -3,6 +3,7 @@ import { agentNativePath } from "@agent-native/core/client/api-path"; import { getBrowserTabId, setClientAppState, + tryCallActionKeepalive, useSession, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; @@ -23,6 +24,7 @@ import { type ProcessBuilderBodyHydrationResponse, type SourceJoinSuggestion, type ContentDatabasePersonalViewOverrides, + type ContentDatabasePersonalViewResponse, type ContentDatabaseView, type ContentDatabaseViewConfig, type ContentDatabaseColumnCalculation, @@ -40,6 +42,7 @@ import { type DocumentPropertyOption, type DocumentPropertyType, type DocumentPropertyValue, + type UpdateContentDatabasePersonalViewRequest, } from "@shared/api"; import { contentDatabaseFormQuestions } from "@shared/database-form"; import { @@ -103,6 +106,7 @@ import { useRef, useState, type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, @@ -356,6 +360,8 @@ export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = export const BUILDER_SOURCE_CONTINUATION_STALL_MS = 5_000; export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000; +let personalViewMutationSequence = Date.now() * 1_000; + export type PersonalDatabaseViewOverrides = ContentDatabasePersonalViewOverrides; @@ -680,12 +686,19 @@ export function DatabaseView({ isActive, }: DatabaseViewProps) { const { data: document } = useDocument(databaseDocumentId); + const personalView = useContentDatabasePersonalView(databaseId); - if (!document?.database || document.database.id !== databaseId) return null; + if ( + !document?.database || + document.database.id !== databaseId || + personalView.isLoading + ) + return null; return ( | null>(null); const [settingsPanel, setSettingsPanel] = useState("main"); - const [viewConfig, setViewConfig] = useState( - defaultDatabaseViewConfig(), + const initialSavedViewConfig = normalizeClientDatabaseViewConfig( + document.database?.viewConfig, ); + const initialViewConfig = applyPersonalDatabaseViewOverrides( + initialSavedViewConfig, + normalizePersonalDatabaseViewOverrides(personalView?.overrides), + ); + const [viewConfig, setViewConfig] = + useState(initialViewConfig); const [savedViewConfig, setSavedViewConfig] = - useState(defaultDatabaseViewConfig()); - const [personalQueryDirty, setPersonalQueryDirty] = useState(false); + useState(initialSavedViewConfig); + const [personalQueryDirty, setPersonalQueryDirty] = useState(() => + databaseViewHasPersonalQueryChanges( + initialViewConfig, + initialSavedViewConfig, + ), + ); const [dateViewMonth, setDateViewMonth] = useState(() => startOfMonth(new Date()), ); @@ -862,11 +887,18 @@ function DatabaseTable({ () => databaseDateViewRange(activeView.type, dateViewMonth), [activeView.type, dateViewMonth], ); - const hydratedViewRef = useRef(""); + const hydratedViewRef = useRef( + databaseViewStateKey(expectedDatabaseId, initialViewConfig), + ); const saveViewTimerRef = useRef | null>(null); const personalViewSaveTimerRef = useRef | null>( null, ); + const pendingPersonalViewSaveRef = + useRef(null); + const unmountingRef = useRef(false); + const updatePersonalViewRef = useRef(updatePersonalView); + updatePersonalViewRef.current = updatePersonalView; const autoContinueBuilderSourceRef = useRef>(new Set()); const builderContinuationWatchdogRef = useRef>(new Map()); const refreshSourceInFlightRef = useRef(null); @@ -1579,7 +1611,10 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); personalViewSaveTimerRef.current = null; } - updatePersonalView.mutate({ databaseId, overrides: null }); + pendingPersonalViewSaveRef.current = null; + updatePersonalView.mutate( + personalDatabaseViewMutationRequest(databaseId, null), + ); setSavedViewConfig(nextViewConfig); setViewConfig(nextViewConfig); setPersonalQueryDirty(false); @@ -1601,15 +1636,15 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); personalViewSaveTimerRef.current = null; } + pendingPersonalViewSaveRef.current = null; setViewConfig((current) => { const nextViewConfig = databaseViewConfigWithSavedQueryState( current, savedViewConfig, ); - updatePersonalView.mutate({ - databaseId, - overrides: null, - }); + updatePersonalView.mutate( + personalDatabaseViewMutationRequest(databaseId, null), + ); setPersonalQueryDirty(false); hydratedViewRef.current = databaseViewStateKey( databaseId, @@ -1619,6 +1654,56 @@ function DatabaseTable({ }); } + const flushPendingPersonalViewSave: () => void = useCallback(() => { + if (unmountingRef.current) return; + if (personalViewSaveTimerRef.current) { + clearTimeout(personalViewSaveTimerRef.current); + personalViewSaveTimerRef.current = null; + } + if (updatePersonalViewRef.current.isPending) { + personalViewSaveTimerRef.current = setTimeout(() => { + flushPendingPersonalViewSave(); + }, 50); + return; + } + const pending = pendingPersonalViewSaveRef.current; + if (!pending) return; + pendingPersonalViewSaveRef.current = null; + updatePersonalViewRef.current.mutate(pending, { + onError: (err) => { + toast.error(dbText("failedToSaveView"), { + description: + err instanceof Error ? err.message : dbText("somethingWentWrong"), + }); + }, + }); + }, []); + + const sendPendingPersonalViewSaveKeepalive = useCallback(() => { + const pending = pendingPersonalViewSaveRef.current; + if (!pending) return; + const attempt = tryCallActionKeepalive( + "update-content-database-personal-view", + pending, + ); + if (!attempt.accepted) { + flushPendingPersonalViewSave(); + return; + } + if (personalViewSaveTimerRef.current) { + clearTimeout(personalViewSaveTimerRef.current); + personalViewSaveTimerRef.current = null; + } + pendingPersonalViewSaveRef.current = null; + void attempt.completion.catch(() => { + if (pendingPersonalViewSaveRef.current) return; + pendingPersonalViewSaveRef.current = pending; + personalViewSaveTimerRef.current = setTimeout(() => { + flushPendingPersonalViewSave(); + }, 300); + }); + }, [flushPendingPersonalViewSave]); + function schedulePersonalDatabaseViewOverrideWrite( databaseId: string, viewConfig: ContentDatabaseViewConfig, @@ -1627,21 +1712,12 @@ function DatabaseTable({ clearTimeout(personalViewSaveTimerRef.current); } const overrides = personalDatabaseViewOverridesFromConfig(viewConfig); + pendingPersonalViewSaveRef.current = personalDatabaseViewMutationRequest( + databaseId, + overrides, + ); personalViewSaveTimerRef.current = setTimeout(() => { - personalViewSaveTimerRef.current = null; - updatePersonalView.mutate( - { databaseId, overrides }, - { - onError: (err) => { - toast.error(dbText("failedToSaveView"), { - description: - err instanceof Error - ? err.message - : dbText("somethingWentWrong"), - }); - }, - }, - ); + flushPendingPersonalViewSave(); }, 300); } @@ -2131,13 +2207,14 @@ function DatabaseTable({ ]); useEffect(() => { if (!data?.database.id) return; - if (personalView.isLoading) return; + if (pendingPersonalViewSaveRef.current || updatePersonalView.isPending) + return; const nextSavedViewConfig = normalizeClientDatabaseViewConfig( data.database.viewConfig, ); const nextViewConfig = applyPersonalDatabaseViewOverrides( nextSavedViewConfig, - normalizePersonalDatabaseViewOverrides(personalView.data?.overrides), + normalizePersonalDatabaseViewOverrides(personalView?.overrides), ); const nextKey = databaseViewStateKey(data.database.id, nextViewConfig); if (hydratedViewRef.current === nextKey) return; @@ -2154,17 +2231,21 @@ function DatabaseTable({ }, [ data?.database.id, data?.database.viewConfig, - personalView.data?.overrides, - personalView.isLoading, + personalView?.overrides, + updatePersonalView.isPending, ]); useEffect(() => { + const handlePageHide = () => { + sendPendingPersonalViewSaveKeepalive(); + }; + window.addEventListener("pagehide", handlePageHide); return () => { - if (personalViewSaveTimerRef.current) { - clearTimeout(personalViewSaveTimerRef.current); - } + window.removeEventListener("pagehide", handlePageHide); + sendPendingPersonalViewSaveKeepalive(); + unmountingRef.current = true; }; - }, []); + }, [flushPendingPersonalViewSave, sendPendingPersonalViewSaveKeepalive]); useEffect(() => { if (!databaseId) return; @@ -2192,7 +2273,7 @@ function DatabaseTable({ ? applyPersonalDatabaseViewOverrides( nextViewConfig, normalizePersonalDatabaseViewOverrides( - personalView.data?.overrides, + personalView?.overrides, ), ) : nextViewConfig, @@ -2210,7 +2291,7 @@ function DatabaseTable({ canEdit, databaseId, personalQueryDirty, - personalView.data?.overrides, + personalView?.overrides, savedViewConfig, updateView, viewConfig, @@ -10865,7 +10946,7 @@ function DatabasePropertyPickerSearch({ onChange(event.target.value)} - onKeyDown={(event) => event.stopPropagation()} + onKeyDown={stopDatabasePickerKeydownPropagation} placeholder={placeholder} aria-label={placeholder} className="h-6 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" @@ -10875,6 +10956,13 @@ function DatabasePropertyPickerSearch({ ); } +function stopDatabasePickerKeydownPropagation( + event: ReactKeyboardEvent, +) { + if (event.key === "Escape" || event.key === "Tab") return; + event.stopPropagation(); +} + function DatabasePropertyPickerItem({ item, selected, @@ -13491,6 +13579,19 @@ function personalDatabaseViewOverridesFromConfig( }; } +function personalDatabaseViewMutationRequest( + databaseId: string, + overrides: PersonalDatabaseViewOverrides | null, +): UpdateContentDatabasePersonalViewRequest { + personalViewMutationSequence += 1; + return { + databaseId, + overrides, + mutationSource: getBrowserTabId(), + mutationSequence: personalViewMutationSequence, + }; +} + export function applyPersonalDatabaseViewOverrides( savedViewConfig: ContentDatabaseViewConfig, overrides: PersonalDatabaseViewOverrides | null, @@ -16964,7 +17065,7 @@ function SortMenu({ className={cn("w-[340px]", sorts.length === 0 && "w-64 p-0")} > {sorts.length === 0 ? ( -
event.stopPropagation()}> +
-
event.stopPropagation()}> +
("update-content-database-personal-view", { + skipActionQueryInvalidation: true, + onMutate: (variables) => { + const queryKey = [ + "action", + "get-content-database-personal-view", + { databaseId: variables.databaseId }, + ] as const; + queryClient.setQueryData(queryKey, { + databaseId: variables.databaseId, + overrides: variables.overrides, + }); + }, onSuccess: (data) => { - if (!databaseId) return; - queryClient.setQueryData( - ["action", "get-content-database-personal-view", { databaseId }], - data, - ); + return queryClient.invalidateQueries({ + queryKey: [ + "action", + "get-content-database-personal-view", + { databaseId: data.databaseId }, + ], + exact: true, + refetchType: "all", + }); + }, + onError: (_error, variables) => { + return queryClient.invalidateQueries({ + queryKey: [ + "action", + "get-content-database-personal-view", + { databaseId: variables.databaseId }, + ], + exact: true, + refetchType: "all", + }); }, }); } diff --git a/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md new file mode 100644 index 0000000000..325050f66a --- /dev/null +++ b/templates/content/changelog/2026-07-23-database-toolbar-menus-dismiss-and-personal-filters-reload-reliably.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-23 +--- + +Database Sort and Filter menus now dismiss cleanly with Escape, restore keyboard focus, and reload personal filters without flashing shared defaults. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 9a0a31840a..f1fe66f6ec 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -344,7 +344,7 @@ export interface ContentDatabaseViewConfig { export const CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION = 2; export interface ContentDatabasePersonalViewOverrides { - version: number; + version: typeof CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION; activeViewId?: string; views: Array<{ id: string; @@ -362,6 +362,8 @@ export interface ContentDatabasePersonalViewResponse { export interface UpdateContentDatabasePersonalViewRequest { databaseId: string; overrides: ContentDatabasePersonalViewOverrides | null; + mutationSource?: string; + mutationSequence?: number; } export interface ContentDatabaseMembership {