From 2516d9ddebb602c6760cbf21899ceb26fda507e4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 15 May 2026 21:54:54 +0800 Subject: [PATCH] Fix database condition selector observers --- .../__tests__/useConditionSelectors.test.tsx | 215 ++++++++++++++++++ src/application/database-yjs/selector.ts | 160 ++++++++----- 2 files changed, 312 insertions(+), 63 deletions(-) create mode 100644 src/application/database-yjs/__tests__/useConditionSelectors.test.tsx diff --git a/src/application/database-yjs/__tests__/useConditionSelectors.test.tsx b/src/application/database-yjs/__tests__/useConditionSelectors.test.tsx new file mode 100644 index 00000000..ab2e6500 --- /dev/null +++ b/src/application/database-yjs/__tests__/useConditionSelectors.test.tsx @@ -0,0 +1,215 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type React from 'react'; +import * as Y from 'yjs'; + +import { + DatabaseContext, + DatabaseContextState, + FieldType, + FilterType, + SortCondition, + TextFilterCondition, + useFiltersSelector, + useFilterSelector, + useSortsSelector, + useSortSelector, +} from '@/application/database-yjs'; +import { + RowId, + YDatabaseField, + YDatabaseFilter, + YDatabaseFilters, + YDatabaseSort, + YDatabaseSorts, + YDatabaseView, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; + +type ConditionFixture = { + databaseDoc: YDoc; + fields: Y.Map; + view: YDatabaseView; + viewId: string; +}; + +const firstFieldId = 'first-field'; +const secondFieldId = 'second-field'; + +function createTextField(fieldId: string) { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.id, fieldId); + field.set(YjsDatabaseKey.name, fieldId); + field.set(YjsDatabaseKey.type, FieldType.RichText); + + return field; +} + +function createTextFilter(id: string, fieldId: string) { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, id); + filter.set(YjsDatabaseKey.field_id, fieldId); + filter.set(YjsDatabaseKey.filter_type, FilterType.Data); + filter.set(YjsDatabaseKey.condition, TextFilterCondition.TextContains); + filter.set(YjsDatabaseKey.content, 'match'); + filter.set(YjsDatabaseKey.type, FieldType.RichText); + + return filter; +} + +function createSort(id: string, fieldId: string) { + const sort = new Y.Map() as YDatabaseSort; + + sort.set(YjsDatabaseKey.id, id); + sort.set(YjsDatabaseKey.field_id, fieldId); + sort.set(YjsDatabaseKey.condition, SortCondition.Ascending); + + return sort; +} + +function createConditionFixture(): ConditionFixture { + const viewId = 'view-id'; + const databaseDoc = new Y.Doc() as unknown as YDoc; + const sharedRoot = databaseDoc.getMap(YjsEditorKey.data_section); + const database = new Y.Map(); + const fields = new Y.Map(); + const views = new Y.Map(); + const view = new Y.Map() as YDatabaseView; + + fields.set(firstFieldId, createTextField(firstFieldId)); + fields.set(secondFieldId, createTextField(secondFieldId)); + views.set(viewId, view); + + database.set(YjsDatabaseKey.id, 'database-id'); + database.set(YjsDatabaseKey.fields, fields); + database.set(YjsDatabaseKey.views, views); + sharedRoot.set(YjsEditorKey.database, database); + + return { + databaseDoc, + fields, + view, + viewId, + }; +} + +function createWrapper(fixture: ConditionFixture, contextOverrides: Partial = {}) { + const contextValue: DatabaseContextState = { + readOnly: false, + databaseDoc: fixture.databaseDoc, + databasePageId: fixture.viewId, + activeViewId: fixture.viewId, + rowMap: {} as Record, + workspaceId: 'workspace-id', + ...contextOverrides, + }; + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('database condition selectors', () => { + it('observes a filters array created after mount', async () => { + const fixture = createConditionFixture(); + const { result } = renderHook(() => useFiltersSelector(), { + wrapper: createWrapper(fixture), + }); + + expect(result.current).toEqual([]); + + const filters = new Y.Array() as YDatabaseFilters; + + act(() => { + fixture.view.set(YjsDatabaseKey.filters, filters); + filters.push([createTextFilter('filter-id', firstFieldId)]); + }); + + await waitFor(() => { + expect(result.current).toEqual([{ id: 'filter-id', fieldId: firstFieldId }]); + }); + }); + + it('updates filter selectors when the filter field changes', async () => { + const fixture = createConditionFixture(); + const filter = createTextFilter('filter-id', firstFieldId); + const filters = new Y.Array() as YDatabaseFilters; + + filters.push([filter]); + fixture.view.set(YjsDatabaseKey.filters, filters); + + const { result: filterListResult } = renderHook(() => useFiltersSelector(), { + wrapper: createWrapper(fixture), + }); + const { result: filterResult } = renderHook(() => useFilterSelector('filter-id'), { + wrapper: createWrapper(fixture), + }); + + await waitFor(() => { + expect(filterListResult.current).toEqual([{ id: 'filter-id', fieldId: firstFieldId }]); + expect(filterResult.current?.fieldId).toBe(firstFieldId); + }); + + act(() => { + filter.set(YjsDatabaseKey.field_id, secondFieldId); + }); + + await waitFor(() => { + expect(filterListResult.current).toEqual([{ id: 'filter-id', fieldId: secondFieldId }]); + expect(filterResult.current?.fieldId).toBe(secondFieldId); + }); + }); + + it('observes a sorts array created after mount', async () => { + const fixture = createConditionFixture(); + const { result } = renderHook(() => useSortsSelector(), { + wrapper: createWrapper(fixture), + }); + + expect(result.current).toEqual([]); + + const sorts = new Y.Array() as YDatabaseSorts; + + act(() => { + fixture.view.set(YjsDatabaseKey.sorts, sorts); + sorts.push([createSort('sort-id', firstFieldId)]); + }); + + await waitFor(() => { + expect(result.current).toEqual([{ id: 'sort-id', fieldId: firstFieldId }]); + }); + }); + + it('updates sort selectors when the sort field changes', async () => { + const fixture = createConditionFixture(); + const sort = createSort('sort-id', firstFieldId); + const sorts = new Y.Array() as YDatabaseSorts; + + sorts.push([sort]); + fixture.view.set(YjsDatabaseKey.sorts, sorts); + + const { result: sortListResult } = renderHook(() => useSortsSelector(), { + wrapper: createWrapper(fixture), + }); + const { result: sortResult } = renderHook(() => useSortSelector('sort-id'), { + wrapper: createWrapper(fixture), + }); + + await waitFor(() => { + expect(sortListResult.current).toEqual([{ id: 'sort-id', fieldId: firstFieldId }]); + expect(sortResult.current?.fieldId).toBe(firstFieldId); + }); + + act(() => { + sort.set(YjsDatabaseKey.field_id, secondFieldId); + }); + + await waitFor(() => { + expect(sortListResult.current).toEqual([{ id: 'sort-id', fieldId: secondFieldId }]); + expect(sortResult.current?.fieldId).toBe(secondFieldId); + }); + }); +}); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 452f19e3..7088acb9 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -104,6 +104,16 @@ function getConditionSignature(sorts?: YDatabaseSorts, filters?: YDatabaseFilter const CONDITION_ROW_LOAD_BATCH_SIZE = 24; const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; +type ConditionReference = { id: string; fieldId: string }; + +function areConditionReferencesEqual(left: ConditionReference[], right: ConditionReference[]) { + return left.length === right.length && left.every((item, index) => { + const rightItem = right[index]; + + return item.id === rightItem?.id && item.fieldId === rightItem.fieldId; + }); +} + /** * Hook to get all database views (tabs) for the database. * @param databasePageId - The main database page ID in the folder structure @@ -425,14 +435,15 @@ export function useDatabaseIdFromField(fieldId: string) { export function useFiltersSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); - const [filters, setFilters] = useState<{ id: string; fieldId: string }[]>([]); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filterOrders = view?.get(YjsDatabaseKey.filters); + const [filters, setFilters] = useState([]); useEffect(() => { - if (!viewId) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const filterOrders = view?.get(YjsDatabaseKey.filters); - - if (!filterOrders) return; + if (!filterOrders) { + setFilters([]); + return; + } const getFilters = () => { const rawData = filterOrders.toJSON(); @@ -458,17 +469,19 @@ export function useFiltersSelector() { }; const observerEvent = () => { - setFilters(getFilters()); + const nextFilters = getFilters(); + + setFilters((prevFilters) => areConditionReferencesEqual(prevFilters, nextFilters) ? prevFilters : nextFilters); }; observerEvent(); - filterOrders.observe(observerEvent); + filterOrders.observeDeep(observerEvent); return () => { - filterOrders.unobserve(observerEvent); + filterOrders.unobserveDeep(observerEvent); }; - }, [database, viewId]); + }, [filterOrders]); return filters; } @@ -477,32 +490,40 @@ export function useFilterSelector(filterId: string) { const database = useDatabase(); const viewId = useDatabaseViewId(); const fields = database?.get(YjsDatabaseKey.fields); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filter = view + ?.get(YjsDatabaseKey.filters) + ?.toArray() + .find((filter) => filter.get(YjsDatabaseKey.id) === filterId); const [filterValue, setFilterValue] = useState(null); useEffect(() => { - if (!viewId) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const filter = view - ?.get(YjsDatabaseKey.filters) - .toArray() - .find((filter) => filter.get(YjsDatabaseKey.id) === filterId); - const field = fields?.get(filter?.get(YjsDatabaseKey.field_id) as FieldId); + if (!filter || !fields) { + setFilterValue(null); + return; + } const observerEvent = () => { - if (!filter || !field) return; + const field = fields.get(filter.get(YjsDatabaseKey.field_id)); + + if (!field) { + setFilterValue(null); + return; + } + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; setFilterValue(parseFilter(fieldType, filter)); }; observerEvent(); - field?.observe(observerEvent); - filter?.observe(observerEvent); + fields.observeDeep(observerEvent); + filter.observeDeep(observerEvent); return () => { - field?.unobserve(observerEvent); - filter?.unobserve(observerEvent); + fields.unobserveDeep(observerEvent); + filter.unobserveDeep(observerEvent); }; - }, [fields, viewId, filterId, database]); + }, [fields, filter]); return filterValue; } @@ -514,6 +535,8 @@ const DEFAULT_ROOT_INFO = { isHierarchical: false, rootType: null, childCount: 0 export function useRootFilterInfo() { const database = useDatabase(); const viewId = useDatabaseViewId(); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filters = view?.get(YjsDatabaseKey.filters); const [rootInfo, setRootInfo] = useState<{ isHierarchical: boolean; rootType: FilterType | null; @@ -521,11 +544,10 @@ export function useRootFilterInfo() { }>(DEFAULT_ROOT_INFO); useEffect(() => { - if (!viewId) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const filters = view?.get(YjsDatabaseKey.filters); - - if (!filters) return; + if (!filters) { + setRootInfo(DEFAULT_ROOT_INFO); + return; + } const observerEvent = () => { if (filters.length === 0) { @@ -571,7 +593,7 @@ export function useRootFilterInfo() { return () => { filters.unobserveDeep(observerEvent); }; - }, [database, viewId]); + }, [filters]); return rootInfo; } @@ -585,14 +607,12 @@ export function useAdvancedFiltersSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); const fields = database?.get(YjsDatabaseKey.fields); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filtersArray = view?.get(YjsDatabaseKey.filters); const [filters, setFilters] = useState([]); useEffect(() => { - if (!viewId || !fields) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const filtersArray = view?.get(YjsDatabaseKey.filters); - - if (!filtersArray) { + if (!fields || !filtersArray) { setFilters([]); return; } @@ -637,7 +657,7 @@ export function useAdvancedFiltersSelector() { return () => { filtersArray.unobserveDeep(observerEvent); }; - }, [database, viewId, fields]); + }, [fields, filtersArray]); return filters; } @@ -649,16 +669,22 @@ export function useAdvancedFilterSelector(filterId: string) { const database = useDatabase(); const viewId = useDatabaseViewId(); const fields = database?.get(YjsDatabaseKey.fields); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filtersArray = view?.get(YjsDatabaseKey.filters); const [filterValue, setFilterValue] = useState(null); useEffect(() => { - if (!viewId || !fields) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const filtersArray = view?.get(YjsDatabaseKey.filters); - - if (!filtersArray || filtersArray.length === 0) return; + if (!fields || !filtersArray) { + setFilterValue(null); + return; + } const observerEvent = () => { + if (filtersArray.length === 0) { + setFilterValue(null); + return; + } + const rootFilter = filtersArray.get(0); if (!rootFilter) { @@ -749,7 +775,7 @@ export function useAdvancedFilterSelector(filterId: string) { return () => { filtersArray.unobserveDeep(observerEvent); }; - }, [database, viewId, fields, filterId]); + }, [fields, filterId, filtersArray]); return filterValue; } @@ -757,14 +783,15 @@ export function useAdvancedFilterSelector(filterId: string) { export function useSortsSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); - const [sorts, setSorts] = useState<{ id: string; fieldId: string }[]>([]); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const sortOrders = view?.get(YjsDatabaseKey.sorts); + const [sorts, setSorts] = useState([]); useEffect(() => { - if (!viewId) return; - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const sortOrders = view?.get(YjsDatabaseKey.sorts); - - if (!sortOrders) return; + if (!sortOrders) { + setSorts([]); + return; + } const getSorts = () => { return (sortOrders.toJSON() as { id: string; field_id: string }[]).map((item) => { @@ -775,16 +802,20 @@ export function useSortsSelector() { }); }; - const observerEvent = () => setSorts(getSorts()); + const observerEvent = () => { + const nextSorts = getSorts(); + + setSorts((prevSorts) => areConditionReferencesEqual(prevSorts, nextSorts) ? prevSorts : nextSorts); + }; setSorts(getSorts()); - sortOrders.observe(observerEvent); + sortOrders.observeDeep(observerEvent); return () => { - sortOrders.unobserve(observerEvent); + sortOrders.unobserveDeep(observerEvent); }; - }, [database, viewId]); + }, [sortOrders]); return sorts; } @@ -800,30 +831,33 @@ export function useSortSelector(sortId: SortId) { const viewId = useDatabaseViewId(); const [sortValue, setSortValue] = useState(null); const views = database?.get(YjsDatabaseKey.views); + const view = views?.get(viewId); + const sort = view + ?.get(YjsDatabaseKey.sorts) + ?.toArray() + .find((sort) => sort.get(YjsDatabaseKey.id) === sortId); useEffect(() => { - if (!viewId) return; - const view = views?.get(viewId); - const sort = view - ?.get(YjsDatabaseKey.sorts) - .toArray() - .find((sort) => sort.get(YjsDatabaseKey.id) === sortId); + if (!sort) { + setSortValue(null); + return; + } const observerEvent = () => { setSortValue({ - fieldId: sort?.get(YjsDatabaseKey.field_id) as FieldId, - condition: Number(sort?.get(YjsDatabaseKey.condition)), - id: sort?.get(YjsDatabaseKey.id) as SortId, + fieldId: sort.get(YjsDatabaseKey.field_id), + condition: Number(sort.get(YjsDatabaseKey.condition)), + id: sort.get(YjsDatabaseKey.id), }); }; observerEvent(); - sort?.observe(observerEvent); + sort.observe(observerEvent); return () => { - sort?.unobserve(observerEvent); + sort.unobserve(observerEvent); }; - }, [viewId, sortId, views]); + }, [sort]); return sortValue; }