|
| 1 | +import { expect } from '@jest/globals'; |
| 2 | +import { act, renderHook } from '@testing-library/react'; |
| 3 | +import { ReactNode } from 'react'; |
| 4 | +import * as Y from 'yjs'; |
| 5 | + |
| 6 | +import { DatabaseContext, DatabaseContextState } from '@/application/database-yjs'; |
| 7 | +import { FieldType, RowMetaKey } from '@/application/database-yjs/database.type'; |
| 8 | +import { useDuplicateRowDispatch } from '@/application/database-yjs/dispatch/row'; |
| 9 | +import { getMetaIdMap, getRowKey } from '@/application/database-yjs/row_meta'; |
| 10 | +import { RowId, YDatabase, YDatabaseField, YDatabaseView, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; |
| 11 | + |
| 12 | +import { createCell, createRowDoc } from './test-helpers'; |
| 13 | + |
| 14 | +jest.mock('@/utils/runtime-config', () => ({ |
| 15 | + getConfigValue: (_key: string, fallback: string) => fallback, |
| 16 | +})); |
| 17 | + |
| 18 | +jest.mock('@/application/db', () => { |
| 19 | + // eslint-disable-next-line @typescript-eslint/no-var-requires |
| 20 | + const Yjs = require('yjs'); |
| 21 | + |
| 22 | + return { |
| 23 | + deleteCollabDB: jest.fn(), |
| 24 | + getCachedProviderDoc: jest.fn(), |
| 25 | + openCollabDB: jest.fn(async () => new Yjs.Doc()), |
| 26 | + }; |
| 27 | +}); |
| 28 | + |
| 29 | +const databaseId = 'database-id'; |
| 30 | +const databaseDocId = '00000000-0000-4000-8000-000000000001'; |
| 31 | +const viewId = 'view-id'; |
| 32 | +const sourceRowId = 'source-row-id'; |
| 33 | +const fieldId = 'name-field-id'; |
| 34 | + |
| 35 | +function createField(fieldId: string): YDatabaseField { |
| 36 | + const field = new Y.Map() as YDatabaseField; |
| 37 | + |
| 38 | + field.set(YjsDatabaseKey.id, fieldId); |
| 39 | + field.set(YjsDatabaseKey.name, 'Name'); |
| 40 | + field.set(YjsDatabaseKey.type, FieldType.RichText); |
| 41 | + |
| 42 | + return field; |
| 43 | +} |
| 44 | + |
| 45 | +function createDatabaseDoc(): YDoc { |
| 46 | + const doc = new Y.Doc({ guid: databaseDocId }) as YDoc; |
| 47 | + const sharedRoot = doc.getMap(YjsEditorKey.data_section); |
| 48 | + const database = new Y.Map() as YDatabase; |
| 49 | + const fields = new Y.Map<YDatabaseField>(); |
| 50 | + const views = new Y.Map<YDatabaseView>(); |
| 51 | + const view = new Y.Map() as YDatabaseView; |
| 52 | + const rowOrders = new Y.Array<{ id: RowId; height: number }>(); |
| 53 | + |
| 54 | + fields.set(fieldId, createField(fieldId)); |
| 55 | + rowOrders.push([{ id: sourceRowId, height: 36 }]); |
| 56 | + view.set(YjsDatabaseKey.row_orders, rowOrders); |
| 57 | + views.set(viewId, view); |
| 58 | + |
| 59 | + database.set(YjsDatabaseKey.id, databaseId); |
| 60 | + database.set(YjsDatabaseKey.fields, fields); |
| 61 | + database.set(YjsDatabaseKey.views, views); |
| 62 | + sharedRoot.set(YjsEditorKey.database, database); |
| 63 | + |
| 64 | + return doc; |
| 65 | +} |
| 66 | + |
| 67 | +function createReferenceRowDoc(options: { |
| 68 | + documentId?: string; |
| 69 | + isEmptyDocument?: boolean; |
| 70 | +} = {}): YDoc { |
| 71 | + const rowDoc = createRowDoc(sourceRowId, databaseId, { |
| 72 | + [fieldId]: createCell(FieldType.RichText, 'Source row'), |
| 73 | + }); |
| 74 | + const meta = new Y.Map<unknown>(); |
| 75 | + const metaKeys = getMetaIdMap(sourceRowId); |
| 76 | + |
| 77 | + if (options.documentId !== undefined) { |
| 78 | + meta.set(metaKeys.get(RowMetaKey.DocumentId) ?? '', options.documentId); |
| 79 | + } |
| 80 | + |
| 81 | + if (options.isEmptyDocument !== undefined) { |
| 82 | + meta.set(metaKeys.get(RowMetaKey.IsDocumentEmpty) ?? '', options.isEmptyDocument); |
| 83 | + } |
| 84 | + |
| 85 | + rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.meta, meta); |
| 86 | + return rowDoc; |
| 87 | +} |
| 88 | + |
| 89 | +function getRowMetaValue(rowDoc: YDoc, rowId: string, key: RowMetaKey) { |
| 90 | + const metaKey = getMetaIdMap(rowId).get(key) ?? ''; |
| 91 | + const meta = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.meta) as Y.Map<unknown>; |
| 92 | + |
| 93 | + return meta.get(metaKey); |
| 94 | +} |
| 95 | + |
| 96 | +function createWrapper({ |
| 97 | + databaseDoc, |
| 98 | + referenceRowDoc, |
| 99 | + createdRows, |
| 100 | + duplicateRowDocument, |
| 101 | +}: { |
| 102 | + databaseDoc: YDoc; |
| 103 | + referenceRowDoc: YDoc; |
| 104 | + createdRows: Map<string, YDoc>; |
| 105 | + duplicateRowDocument: NonNullable<DatabaseContextState['duplicateRowDocument']>; |
| 106 | +}) { |
| 107 | + const contextValue: DatabaseContextState = { |
| 108 | + readOnly: false, |
| 109 | + databaseDoc, |
| 110 | + databasePageId: 'database-page-id', |
| 111 | + activeViewId: viewId, |
| 112 | + rowMap: { |
| 113 | + [sourceRowId]: referenceRowDoc, |
| 114 | + }, |
| 115 | + workspaceId: 'workspace-id', |
| 116 | + createRow: async (rowKey: string) => { |
| 117 | + const rowDoc = new Y.Doc({ guid: rowKey }) as YDoc; |
| 118 | + |
| 119 | + createdRows.set(rowKey, rowDoc); |
| 120 | + return rowDoc; |
| 121 | + }, |
| 122 | + duplicateRowDocument, |
| 123 | + }; |
| 124 | + |
| 125 | + return ({ children }: { children: ReactNode }) => ( |
| 126 | + <DatabaseContext.Provider value={contextValue}>{children}</DatabaseContext.Provider> |
| 127 | + ); |
| 128 | +} |
| 129 | + |
| 130 | +describe('useDuplicateRowDispatch', () => { |
| 131 | + it('does not create or duplicate a row page when the source row is document-empty', async () => { |
| 132 | + const databaseDoc = createDatabaseDoc(); |
| 133 | + const referenceRowDoc = createReferenceRowDoc({ isEmptyDocument: true }); |
| 134 | + const createdRows = new Map<string, YDoc>(); |
| 135 | + const duplicateRowDocument = jest.fn().mockResolvedValue(undefined); |
| 136 | + const { result } = renderHook(() => useDuplicateRowDispatch(), { |
| 137 | + wrapper: createWrapper({ databaseDoc, referenceRowDoc, createdRows, duplicateRowDocument }), |
| 138 | + }); |
| 139 | + let duplicatedRowId = ''; |
| 140 | + |
| 141 | + await act(async () => { |
| 142 | + duplicatedRowId = await result.current(sourceRowId); |
| 143 | + }); |
| 144 | + |
| 145 | + const createdRowDoc = createdRows.get(getRowKey(databaseDocId, duplicatedRowId)); |
| 146 | + |
| 147 | + expect(createdRowDoc).toBeDefined(); |
| 148 | + expect(getRowMetaValue(createdRowDoc as YDoc, duplicatedRowId, RowMetaKey.IsDocumentEmpty)).toBe(true); |
| 149 | + expect(duplicateRowDocument).not.toHaveBeenCalled(); |
| 150 | + }); |
| 151 | + |
| 152 | + it('keeps duplicated rows with unknown document state marked empty until content is confirmed', async () => { |
| 153 | + const databaseDoc = createDatabaseDoc(); |
| 154 | + const referenceRowDoc = createReferenceRowDoc(); |
| 155 | + const createdRows = new Map<string, YDoc>(); |
| 156 | + const duplicateRowDocument = jest.fn().mockResolvedValue(undefined); |
| 157 | + const { result } = renderHook(() => useDuplicateRowDispatch(), { |
| 158 | + wrapper: createWrapper({ databaseDoc, referenceRowDoc, createdRows, duplicateRowDocument }), |
| 159 | + }); |
| 160 | + let duplicatedRowId = ''; |
| 161 | + |
| 162 | + await act(async () => { |
| 163 | + duplicatedRowId = await result.current(sourceRowId); |
| 164 | + }); |
| 165 | + |
| 166 | + const createdRowDoc = createdRows.get(getRowKey(databaseDocId, duplicatedRowId)); |
| 167 | + |
| 168 | + expect(createdRowDoc).toBeDefined(); |
| 169 | + expect(getRowMetaValue(createdRowDoc as YDoc, duplicatedRowId, RowMetaKey.IsDocumentEmpty)).toBe(true); |
| 170 | + expect(duplicateRowDocument).toHaveBeenCalledWith(databaseId, sourceRowId, duplicatedRowId, undefined); |
| 171 | + }); |
| 172 | + |
| 173 | + it('still requests row page duplication for a known non-empty source document', async () => { |
| 174 | + const sourceDocumentId = 'source-document-id'; |
| 175 | + const databaseDoc = createDatabaseDoc(); |
| 176 | + const referenceRowDoc = createReferenceRowDoc({ |
| 177 | + documentId: sourceDocumentId, |
| 178 | + isEmptyDocument: false, |
| 179 | + }); |
| 180 | + const createdRows = new Map<string, YDoc>(); |
| 181 | + const duplicateRowDocument = jest.fn().mockResolvedValue(undefined); |
| 182 | + const { result } = renderHook(() => useDuplicateRowDispatch(), { |
| 183 | + wrapper: createWrapper({ databaseDoc, referenceRowDoc, createdRows, duplicateRowDocument }), |
| 184 | + }); |
| 185 | + let duplicatedRowId = ''; |
| 186 | + |
| 187 | + await act(async () => { |
| 188 | + duplicatedRowId = await result.current(sourceRowId); |
| 189 | + }); |
| 190 | + |
| 191 | + const createdRowDoc = createdRows.get(getRowKey(databaseDocId, duplicatedRowId)); |
| 192 | + |
| 193 | + expect(createdRowDoc).toBeDefined(); |
| 194 | + expect(getRowMetaValue(createdRowDoc as YDoc, duplicatedRowId, RowMetaKey.IsDocumentEmpty)).toBe(false); |
| 195 | + expect(duplicateRowDocument).toHaveBeenCalledWith(databaseId, sourceRowId, duplicatedRowId, undefined); |
| 196 | + }); |
| 197 | +}); |
0 commit comments