diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 579dddd1..503f5620 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -28,6 +28,10 @@ "%interlinearizer_redone_catalogDelete%": "Redid deleting the analysis of {form} ({count} uses).", "%interlinearizer_undone_catalogDeleteUnused%": "Undid deleting the analysis of {form}.", "%interlinearizer_redone_catalogDeleteUnused%": "Redid deleting the analysis of {form}.", + "%interlinearizer_undone_catalogDeleteChecked%": "Undid deleting {count} analyses ({usageCount} uses).", + "%interlinearizer_redone_catalogDeleteChecked%": "Redid deleting {count} analyses ({usageCount} uses).", + "%interlinearizer_undone_catalogClearGlossChecked%": "Undid clearing the gloss on {count} analyses.", + "%interlinearizer_redone_catalogClearGlossChecked%": "Redid clearing the gloss on {count} analyses.", "%interlinearizer_undone_wipeBook%": "Undid wiping {book}.", "%interlinearizer_redone_wipeBook%": "Redid wiping {book}.", "%interlinearizer_undone_wipeAll%": "Undid wiping the whole draft.", @@ -129,8 +133,16 @@ "%interlinearizer_analysisCatalog_mergedNoGloss%": "Merged into {form} — now {count} uses.", "%interlinearizer_analysisCatalog_mergedDismiss%": "Dismiss", "%interlinearizer_analysisCatalog_draftStranded%": "An edit elsewhere removed the analysis of {form}, discarding the breakdown you were typing for it.", + "%interlinearizer_analysisCatalog_check%": "Select the analysis of {form}", + "%interlinearizer_analysisCatalog_checkAll%": "Select every listed analysis", + "%interlinearizer_analysisCatalog_checkedSummary%": "{count} selected · {usageCount} uses", + "%interlinearizer_analysisCatalog_clearGlossChecked%": "Clear gloss", + "%interlinearizer_analysisCatalog_clearedGlossDeleted%": "Cleared {count} glosses; {deletedCount} analyses had nothing else and were deleted ({usageCount} uses).", + "%interlinearizer_analysisCatalog_clearedGlossDeletedAndMerged%": "Cleared {count} glosses; {deletedCount} analyses had nothing else and were deleted ({usageCount} uses), and {mergedCount} became identical to another and were merged into it.", + "%interlinearizer_analysisCatalog_clearedGlossMerged%": "Cleared {count} glosses; {mergedCount} analyses became identical to another and were merged into it.", "%interlinearizer_analysisCatalog_delete%": "Delete", "%interlinearizer_analysisCatalog_deleted%": "Deleted the analysis of {form}.", + "%interlinearizer_analysisCatalog_deletedChecked%": "Deleted {count} analyses ({usageCount} uses).", "%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses are left with no analysis.", "%interlinearizer_analysisCatalog_deleteBlankNone%": "No token was analyzed as this, so nothing on screen changed.", "%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses fall back to {gloss}.", @@ -149,6 +161,8 @@ "%interlinearizer_analysisCatalog_discardForSwitchConfirm%": "Discard and switch", "%interlinearizer_analysisCatalog_discardForStalePrompt%": "You have typed a morpheme breakdown but not saved it. This change removes the analysis it belongs to, which discards it.", "%interlinearizer_analysisCatalog_discardForStaleConfirm%": "Discard and continue", + "%interlinearizer_analysisCatalog_discardForClearGlossPrompt%": "You have typed a morpheme breakdown but not saved it. Clearing these glosses removes the analysis it belongs to, which discards it.", + "%interlinearizer_analysisCatalog_discardForClearGlossConfirm%": "Discard and clear", "%interlinearizer_projectSettings_title%": "Interlinearizer", "%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll", diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 6d818e1e..692b06a8 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -300,6 +300,20 @@ function mergePromoteFor(analysisId: string): HTMLElement { return within(mergeCandidateFor(analysisId)).getByTestId('catalog-merge-promote'); } +/** A step grouper that runs each action as it comes, recording each step's summary. */ +function spyOnSteps() { + const summaries: StepSummary[] = []; + const asOneStep = ( + action: () => T, + summary: StepSummary | ((result: T) => StepSummary), + ): T => { + const result = action(); + summaries.push(typeof summary === 'function' ? summary(result) : summary); + return result; + }; + return { asOneStep, summaries }; +} + describe('AnalysisCatalogPanel', () => { beforeEach(() => { claimedFocusRequest = undefined; @@ -3853,20 +3867,6 @@ describe('AnalysisCatalogPanel', () => { }); describe('undo steps', () => { - /** A step grouper that runs each action as it comes, recording each step's summary. */ - function spyOnSteps() { - const summaries: StepSummary[] = []; - const asOneStep = ( - action: () => T, - summary: StepSummary | ((result: T) => StepSummary), - ): T => { - const result = action(); - summaries.push(typeof summary === 'function' ? summary(result) : summary); - return result; - }; - return { asOneStep, summaries }; - } - it('summarizes a gloss edit as an edit to its analysis', async () => { const { asOneStep, summaries } = spyOnSteps(); renderPanel({ @@ -4338,4 +4338,468 @@ describe('AnalysisCatalogPanel', () => { }); }); }); + + describe('acting on a selection', () => { + /** Three analyses of three forms, each used once. */ + const THREE_FORMS: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'λόγος', gloss: { en: 'word' } }, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'θεός', gloss: { en: 'God' } }, + ], + tokenAnalysisLinks: [ + link('ta-1', 'GEN 1:1:0'), + link('ta-2', 'GEN 1:1:2', 'approved', 'λόγος'), + link('ta-3', 'GEN 1:1:4', 'approved', 'θεός'), + ], + }; + + /** Checks the row for each of `analysisIds`. */ + async function select(...analysisIds: string[]): Promise { + // Chained: each click re-renders the list the next row is found in. + await analysisIds.reduce( + (clicked, analysisId) => + clicked.then(() => + userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-check')), + ), + Promise.resolve(), + ); + } + + it('states how many rows are checked and how many uses they have', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_checkedSummary%': '{count} checked, {usageCount} uses', + }); + renderPanel({ + analysis: { + ...THREE_FORMS, + tokenAnalysisLinks: [...THREE_FORMS.tokenAnalysisLinks, link('ta-1', 'GEN 1:2:0')], + }, + }); + + await select('ta-1', 'ta-3'); + + expect(screen.getByTestId('catalog-selection-summary')).toHaveTextContent( + '2 checked, 3 uses', + ); + }); + + it('checks every listed row at once, including rows not yet scrolled to', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_checkedSummary%': '{count} checked', + }); + // More rows than the list first mounts, so some are listed without being in the document. + renderPanel({ + analysis: { + ...emptyAnalysis(), + tokenAnalyses: Array.from({ length: 60 }, (_unused, index) => ({ + ...FIXTURE_STAMPS, + id: `ta-${index}`, + surfaceText: `word${index}`, + })), + }, + }); + + await userEvent.click(screen.getByTestId('catalog-check-all')); + + expect(screen.getByTestId('catalog-selection-summary')).toHaveTextContent('60 checked'); + }); + + it('offers nothing to check on a read-only analysis', () => { + renderPanel({ analysis: THREE_FORMS, readOnly: true }); + + expect(screen.queryByTestId('catalog-row-check')).not.toBeInTheDocument(); + expect(screen.queryByTestId('catalog-check-all')).not.toBeInTheDocument(); + }); + + /** {@link THREE_FORMS} with a breakdown apiece, so clearing a gloss leaves each record standing. */ + const THREE_BROKEN_DOWN: TextAnalysis = { + ...THREE_FORMS, + tokenAnalyses: THREE_FORMS.tokenAnalyses.map((ta) => ({ + ...ta, + morphemes: [ + { ...FIXTURE_STAMPS, id: `${ta.id}-m`, form: ta.surfaceText, writingSystem: 'el' }, + ], + })), + }; + + it('clears the gloss on every checked row, leaving the rest', async () => { + renderPanel({ analysis: THREE_BROKEN_DOWN }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(within(rowFor('ta-1')).getByTestId('catalog-row-gloss')).not.toHaveTextContent( + 'beginning', + ); + expect(within(rowFor('ta-2')).getByTestId('catalog-row-gloss')).toHaveTextContent('word'); + expect(within(rowFor('ta-3')).getByTestId('catalog-row-gloss')).not.toHaveTextContent('God'); + }); + + it("clears the checked rows' glosses as one undo step, counting their rows", async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ asOneStep, analysis: THREE_BROKEN_DOWN }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(summaries).toEqual([{ kind: 'catalogClearGlossChecked', count: 2 }]); + }); + + /** Three analyses of one form, `ta-2` the most used, beside one of another form. */ + const HOMOGRAPHS_AND_ONE_OTHER: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + { ...FIXTURE_STAMPS, id: 'ta-4', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [ + link('ta-1', 'GEN 1:1:0'), + link('ta-2', 'GEN 1:3:4'), + link('ta-2', 'GEN 1:5:0'), + link('ta-3', 'GEN 1:6:0'), + link('ta-4', 'GEN 1:1:2', 'approved', 'λόγος'), + ], + }; + + it('opens the merge on the checked rows, the most used of them surviving', async () => { + renderPanel({ analysis: HOMOGRAPHS_AND_ONE_OTHER }); + + await select('ta-1', 'ta-2'); + await userEvent.click(screen.getByTestId('catalog-selection-merge')); + + expect(mergeCandidateIds()[0]).toBe('ta-2'); + expect(mergeCheckFor('ta-1')).toBeChecked(); + expect(mergeCheckFor('ta-3')).not.toBeChecked(); + }); + + it('offers no merge of checked rows that span forms', async () => { + renderPanel({ analysis: HOMOGRAPHS_AND_ONE_OTHER }); + + await select('ta-1', 'ta-2', 'ta-4'); + + expect(screen.queryByTestId('catalog-selection-merge')).not.toBeInTheDocument(); + }); + + it('offers no merge of a single checked row', async () => { + renderPanel({ analysis: HOMOGRAPHS_AND_ONE_OTHER }); + + await select('ta-1'); + + expect(screen.queryByTestId('catalog-selection-merge')).not.toBeInTheDocument(); + }); + + /** Two analyses of one form, `ta-1` differing from `ta-2` only by its gloss, beside `extra`. */ + function oneFoldingRecord(...extra: TextAnalysis['tokenAnalyses']): TextAnalysis { + const morpheme = (id: string) => ({ + ...FIXTURE_STAMPS, + id, + form: 'ἀρχῇ', + writingSystem: 'el', + }); + return { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'ἀρχῇ', + gloss: { en: 'beginning' }, + morphemes: [morpheme('ta-1-m')], + }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', morphemes: [morpheme('ta-2-m')] }, + ...extra, + ], + tokenAnalysisLinks: [ + link('ta-1', 'GEN 1:1:0'), + link('ta-2', 'GEN 1:3:4'), + ...extra.map((ta) => link(ta.id, 'GEN 1:1:2', 'approved', ta.surfaceText)), + ], + }; + } + + /** Expands the row and types a re-segmentation into it without saving. */ + async function typeUnsavedBreakdown(analysisId: string) { + await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-toggle')); + await openBreakdown(rowFor(analysisId)); + const input = within(rowFor(analysisId)).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + } + + describe('deleting over an unsaved breakdown', () => { + it('asks before deleting checked rows when one holds an unsaved breakdown', async () => { + renderPanel({ analysis: THREE_FORMS }); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(listedAnalysisIds()).toHaveLength(3); + }); + + it('deletes the checked rows once the draft is given up', async () => { + renderPanel({ analysis: THREE_FORMS }); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + }); + + it('reports no draft stranded by deleting the row it re-stated the breakdown of', async () => { + renderPanel({ analysis: THREE_FORMS }); + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-toggle')); + await openBreakdown(rowFor('ta-3')); + await select('ta-1', 'ta-3'); + + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + }); + + describe('clearing glosses over an unsaved breakdown', () => { + it('asks before clearing a gloss that leaves a drafted row with nothing', async () => { + renderPanel({ analysis: THREE_FORMS }); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(screen.getByTestId('catalog-close-prompt')).toHaveTextContent( + '%interlinearizer_analysisCatalog_discardForClearGlossPrompt%', + ); + expect(listedAnalysisIds()).toHaveLength(3); + }); + + it('clears the glosses once the draft is given up', async () => { + renderPanel({ analysis: THREE_FORMS }); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + + it('asks before clearing a gloss that folds a drafted row into an identical one', async () => { + renderPanel({ analysis: oneFoldingRecord() }); + await typeUnsavedBreakdown('ta-1'); + await select('ta-1'); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(screen.getByTestId('catalog-close-prompt')).toHaveTextContent( + '%interlinearizer_analysisCatalog_discardForClearGlossPrompt%', + ); + }); + + it('asks about a draft an edit made while asking puts in the way of the clear', async () => { + const base = oneFoldingRecord({ + ...FIXTURE_STAMPS, + id: 'ta-3', + surfaceText: 'ἀρχῇ', + gloss: { en: 'start' }, + }); + renderPanelWithGlossEditing({ + analysis: { + ...base, + tokenAnalyses: base.tokenAnalyses.map((ta) => + ta.id === 'ta-2' ? { ...ta, gloss: { en: 'origin' } } : ta, + ), + }, + }); + await typeUnsavedBreakdown('ta-1'); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + // Leaves `ta-2` what clearing `ta-1` would make it, so the clear now folds `ta-1` away. + act(() => editGloss('GEN 1:3:4', 'ἀρχῇ', '')); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(screen.getByTestId('catalog-close-prompt')).toHaveTextContent( + '%interlinearizer_analysisCatalog_discardForClearGlossPrompt%', + ); + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + + it('keeps a draft without asking when its row survives the clear', async () => { + renderPanel({ analysis: THREE_BROKEN_DOWN }); + await typeUnsavedBreakdown('ta-3'); + await select('ta-1', 'ta-3'); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); + expect(within(rowFor('ta-3')).getByTestId('morpheme-breakdown-input')).toHaveValue( + 'λογ ος', + ); + }); + + it('reports no draft stranded by clearing the row it re-stated the breakdown of', async () => { + renderPanel({ analysis: THREE_FORMS }); + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-toggle')); + await openBreakdown(rowFor('ta-3')); + await select('ta-1', 'ta-3'); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + }); + + it('takes down a merge notice when deleting the checked rows', async () => { + renderPanel({ analysis: HOMOGRAPHS_AND_ONE_OTHER }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + const input = within(rowFor('ta-1')).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'beginning'); + await userEvent.tab(); + await select('ta-2'); + + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + + it("takes down a merge notice when clearing the checked rows' glosses", async () => { + renderPanel({ analysis: HOMOGRAPHS_AND_ONE_OTHER }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + const input = within(rowFor('ta-1')).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'beginning'); + await userEvent.tab(); + await select('ta-4'); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + + it('announces the analyses a gloss clear deleted, having left them with nothing', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_clearedGlossDeleted%': + '{count} cleared, {deletedCount} deleted, {usageCount} uses', + }); + const announceUndoable = jest.fn(); + renderPanel({ + announceUndoable, + analysis: { + ...THREE_BROKEN_DOWN, + // Gloss alone, so clearing it leaves the record with nothing. + tokenAnalyses: THREE_BROKEN_DOWN.tokenAnalyses.map((ta) => + ta.id === 'ta-1' ? { ...ta, morphemes: undefined } : ta, + ), + tokenAnalysisLinks: [...THREE_FORMS.tokenAnalysisLinks, link('ta-1', 'GEN 1:2:0')], + }, + }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(announceUndoable).toHaveBeenCalledWith('2 cleared, 1 deleted, 2 uses'); + }); + + it('announces the analyses a gloss clear merged into an identical one', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_clearedGlossMerged%': + '{count} cleared, {mergedCount} merged', + }); + const announceUndoable = jest.fn(); + renderPanel({ announceUndoable, analysis: oneFoldingRecord() }); + + await select('ta-1'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(announceUndoable).toHaveBeenCalledWith('1 cleared, 1 merged'); + }); + + it('announces both the deleted and the merged analyses of one gloss clear', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_clearedGlossDeletedAndMerged%': + '{count} cleared, {deletedCount} deleted, {usageCount} uses, {mergedCount} merged', + }); + const announceUndoable = jest.fn(); + renderPanel({ + announceUndoable, + analysis: oneFoldingRecord({ + ...FIXTURE_STAMPS, + id: 'ta-3', + surfaceText: 'λόγος', + gloss: { en: 'word' }, + }), + }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(announceUndoable).toHaveBeenCalledWith('2 cleared, 1 deleted, 1 uses, 1 merged'); + }); + + it('announces nothing for a gloss clear that removed no analysis', async () => { + const announceUndoable = jest.fn(); + renderPanel({ announceUndoable, analysis: THREE_BROKEN_DOWN }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(announceUndoable).not.toHaveBeenCalled(); + }); + + it('lets go of a checked row a search stops listing, for good', async () => { + renderPanel({ analysis: THREE_FORMS }); + await select('ta-1', 'ta-3'); + + await userEvent.type(searchBox(), 'θεός'); + await userEvent.clear(searchBox()); + + expect(within(rowFor('ta-1')).getByTestId('catalog-row-check')).not.toBeChecked(); + expect(within(rowFor('ta-3')).getByTestId('catalog-row-check')).toBeChecked(); + }); + + it('deletes every selected row, leaving the rest', async () => { + renderPanel({ analysis: THREE_FORMS }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + }); + + it('deletes the checked rows as one undo step, counting their rows and uses', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ asOneStep, analysis: THREE_FORMS }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(summaries).toEqual([{ kind: 'catalogDeleteChecked', count: 2, usageCount: 2 }]); + }); + + it('announces the deletion of the checked rows', async () => { + const announceUndoable = jest.fn(); + renderPanel({ announceUndoable, analysis: THREE_FORMS }); + + await select('ta-1', 'ta-3'); + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(announceUndoable).toHaveBeenCalledWith( + '%interlinearizer_analysisCatalog_deletedChecked%', + ); + }); + }); }); diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 04de237f..b5e9dfc0 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -5,6 +5,7 @@ import { act, render, renderHook, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AssignmentStatus, + MorphemeAnalysis, TextAnalysis, TokenAnalysis, TokenAnalysisLink, @@ -24,6 +25,7 @@ import { useStaleLocationDispatch, useStaleMorphemesAnalysisId, useStaleLocationReclaims, + useGlossClearReclaims, useSegmentHasApprovedTranslation, useSegmentsWithApprovedTranslation, useStaleFreeTranslationDispatch, @@ -402,7 +404,7 @@ describe('edit locations', () => { }); act(() => { - result.current.deleteAnalysis('GEN 1:1:0-analysis'); + result.current.deleteAnalyses(['GEN 1:1:0-analysis']); }); expect(onSave).toHaveBeenLastCalledWith(expect.anything(), undefined); @@ -1938,6 +1940,64 @@ describe('useStaleLocationReclaims', () => { }); }); +describe('useGlossClearReclaims', () => { + const morpheme = (id: string): MorphemeAnalysis => ({ + ...FIXTURE_STAMPS, + id, + form: 'ἀρχῇ', + writingSystem: 'grc', + }); + + it('reports a record the clear leaves with nothing', () => { + const { result } = renderStoreHook(() => useGlossClearReclaims(), { + initialAnalysis: twoHomographs([approvedLink('ta-1', 'tok-1')]), + }); + + expect(result.current(['ta-1'])).toEqual(['ta-1']); + }); + + it('reports a record the clear folds into an identical sibling', () => { + const { result } = renderStoreHook(() => useGlossClearReclaims(), { + initialAnalysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'ἀρχῇ', + gloss: { und: 'start' }, + morphemes: [morpheme('m-1')], + }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', morphemes: [morpheme('m-2')] }, + ], + tokenAnalysisLinks: [approvedLink('ta-1', 'tok-1'), approvedLink('ta-2', 'tok-2')], + }, + }); + + expect(result.current(['ta-1'])).toEqual(['ta-1']); + }); + + it('reports nothing for a record left holding other content', () => { + const { result } = renderStoreHook(() => useGlossClearReclaims(), { + initialAnalysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'ἀρχῇ', + gloss: { und: 'start' }, + morphemes: [morpheme('m-1')], + }, + ], + tokenAnalysisLinks: [approvedLink('ta-1', 'tok-1')], + }, + }); + + expect(result.current(['ta-1'])).toEqual([]); + }); +}); + describe('useAnalysisRowDispatch', () => { it('reports an ordinary edit as leaving the record standing', () => { const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { @@ -2037,6 +2097,72 @@ describe('useAnalysisRowDispatch', () => { expect(outcome?.kind).toBe('merged'); }); + it('deletes several records in one save', () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + onSave, + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + }); + + act(() => { + result.current.deleteAnalyses(['ta-1', 'ta-2']); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenLastCalledWith( + expect.objectContaining({ tokenAnalyses: [], tokenAnalysisLinks: [] }), + undefined, + ); + }); + + it('clears the gloss on several records in one save', () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + onSave, + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + }); + + act(() => { + result.current.clearGlosses(['ta-1', 'ta-2']); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenLastCalledWith( + expect.objectContaining({ tokenAnalyses: [] }), + undefined, + ); + }); + + it('reports the records a gloss clear left with nothing, which it deleted', () => { + const analysis = twoHomographs([approvedLink('ta-1', 'tok-1'), approvedLink('ta-2', 'tok-2')]); + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: { + ...analysis, + tokenAnalyses: analysis.tokenAnalyses.map((ta) => + ta.id === 'ta-2' + ? { + ...ta, + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'ἀρχ', writingSystem: 'el' }], + } + : ta, + ), + }, + }); + + let deletedIds: readonly string[] | undefined; + act(() => { + deletedIds = result.current.clearGlosses(['ta-1', 'ta-2']); + }); + + expect(deletedIds).toEqual(['ta-1']); + }); + it('throws when called outside an AnalysisStoreProvider', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => renderHook(() => useAnalysisRowDispatch())).toThrow( diff --git a/src/__tests__/components/CatalogList.test.tsx b/src/__tests__/components/CatalogList.test.tsx new file mode 100644 index 00000000..4de88f38 --- /dev/null +++ b/src/__tests__/components/CatalogList.test.tsx @@ -0,0 +1,55 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import CatalogList from '../../components/CatalogList'; + +/** The list holding `rows`, with both refs recorded. */ +function renderList(rows: readonly string[]) { + const scrollRef = jest.fn(); + const sentinelRef = jest.fn(); + render( + + {rows.map((id) => ( +
  • + {id} +
  • + ))} +
    , + ); + return { scrollRef, sentinelRef }; +} + +describe('CatalogList', () => { + it('lists the rows it is given, in order', () => { + renderList(['a', 'b']); + + expect(screen.getAllByTestId('row').map((el) => el.textContent)).toEqual(['a', 'b']); + }); + + it('places the sentinel after the last row', () => { + renderList(['a', 'b']); + + expect(screen.getByRole('list').lastElementChild).toBe( + screen.getByTestId('catalog-rows-sentinel'), + ); + }); + + it('hides the sentinel from assistive tech', () => { + renderList(['a']); + + expect(screen.getByTestId('catalog-rows-sentinel')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('hands over the element the rows scroll in', () => { + const { scrollRef } = renderList(['a']); + + expect(scrollRef).toHaveBeenCalledWith(screen.getByRole('list')); + }); + + it('hands over the sentinel', () => { + const { sentinelRef } = renderList(['a']); + + expect(sentinelRef).toHaveBeenCalledWith(screen.getByTestId('catalog-rows-sentinel')); + }); +}); diff --git a/src/__tests__/components/CatalogRowHeader.test.tsx b/src/__tests__/components/CatalogRowHeader.test.tsx new file mode 100644 index 00000000..58ec514e --- /dev/null +++ b/src/__tests__/components/CatalogRowHeader.test.tsx @@ -0,0 +1,161 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TooltipProvider } from 'platform-bible-react'; +import type { ComponentProps } from 'react'; +import CatalogRowHeader, { ROW_HEADER_STRING_KEYS } from '../../components/CatalogRowHeader'; +import type { CatalogRow } from '../../utils/analysis-query'; + +/** Each key resolving to itself, except the check box's label, which names the row's form. */ +const STRINGS = { + ...Object.fromEntries(ROW_HEADER_STRING_KEYS.map((k) => [k, k])), + '%interlinearizer_analysisCatalog_check%': 'Select {form}', +}; + +/** A row of one analysis, carrying only what a case sets beyond its form and gloss. */ +function row(overrides: Partial = {}): CatalogRow { + return { + analysisId: 'ta-1', + surfaceText: 'λόγος', + gloss: 'word', + morphemes: [], + usageCount: 0, + usageCountInBook: 0, + usages: [], + staleLocations: [], + books: new Set(), + searchText: '', + ...overrides, + }; +} + +/** The header, collapsed and checkable, with every callback stubbed unless a test says otherwise. */ +function renderHeader(overrides: Partial> = {}) { + const props = { + row: row(), + usageCountInBookLabel: 'uses in Genesis', + isExpanded: false, + onToggle: jest.fn(), + isChecked: false, + onCheckedChange: jest.fn(), + localizedStrings: STRINGS, + ...overrides, + }; + render( + + + , + ); + return props; +} + +describe('CatalogRowHeader', () => { + it('names the analysis by its surface form and gloss', () => { + renderHeader(); + + expect(screen.getByTestId('catalog-row-surface')).toHaveTextContent('λόγος'); + expect(screen.getByTestId('catalog-row-gloss')).toHaveTextContent('word'); + }); + + it('offers the surface form and gloss in full, both being truncated to one line', () => { + renderHeader(); + + expect(screen.getByTestId('catalog-row-surface')).toHaveAttribute('title', 'λόγος'); + expect(screen.getByTestId('catalog-row-gloss')).toHaveAttribute('title', 'word'); + }); + + it('marks a missing gloss with the localized placeholder', () => { + renderHeader({ + row: row({ gloss: '' }), + localizedStrings: { ...STRINGS, '%interlinearizer_analysisCatalog_noGloss%': '(none)' }, + }); + + expect(screen.getByTestId('catalog-row-gloss')).toHaveTextContent('(none)'); + }); + + it('marks a missing gloss with a dash until the placeholder resolves', () => { + renderHeader({ row: row({ gloss: '' }) }); + + expect(screen.getByTestId('catalog-row-gloss')).toHaveTextContent('—'); + }); + + it('spells out what each usage count means for assistive tech', () => { + renderHeader({ row: row({ usageCount: 5, usageCountInBook: 2 }) }); + + expect(screen.getByTestId('catalog-row-usage-count')).toHaveTextContent( + '5 %interlinearizer_analysisCatalog_usageCount%', + ); + expect(screen.getByTestId('catalog-row-usage-count-in-book')).toHaveTextContent( + '2 uses in Genesis', + ); + }); + + it('counts the places the analysis went stale at', () => { + const stale = { tokenRef: 'GEN 1:1:0', book: 'GEN', chapter: 1, verse: 1, charStart: 0 }; + renderHeader({ row: row({ staleLocations: [stale, { ...stale, tokenRef: 'GEN 1:2:0' }] }) }); + + expect(screen.getByTestId('catalog-row-stale-count')).toHaveTextContent('2'); + }); + + it('shows no stale count for an analysis with none', () => { + renderHeader(); + + expect(screen.queryByTestId('catalog-row-stale-count')).not.toBeInTheDocument(); + }); + + it('leaves the toggle unlabeled so that its own content names it', () => { + renderHeader(); + + expect(screen.getByTestId('catalog-row-toggle')).not.toHaveAttribute('aria-label'); + }); + + it('marks a collapsed row as collapsed', () => { + renderHeader(); + + expect(screen.getByTestId('catalog-row-toggle')).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('chevron-right-icon')).toBeInTheDocument(); + }); + + it('marks an expanded row as expanded', () => { + renderHeader({ isExpanded: true }); + + expect(screen.getByTestId('catalog-row-toggle')).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('chevron-down-icon')).toBeInTheDocument(); + }); + + it('asks for the row to be toggled', async () => { + const { onToggle } = renderHeader(); + + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('labels the check box with the form it selects', () => { + renderHeader(); + + expect(screen.getByTestId('catalog-row-check')).toHaveAccessibleName('Select λόγος'); + }); + + it('shows the row as checked', () => { + renderHeader({ isChecked: true }); + + expect(screen.getByTestId('catalog-row-check')).toBeChecked(); + }); + + it('asks for the row to be checked by its analysis', async () => { + const { onCheckedChange } = renderHeader(); + + await userEvent.click(screen.getByTestId('catalog-row-check')); + + expect(onCheckedChange).toHaveBeenCalledWith('ta-1', true); + }); + + it('offers no check box without a way to report it', () => { + renderHeader({ onCheckedChange: undefined }); + + expect(screen.queryByTestId('catalog-row-check')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/CatalogSelectionBar.test.tsx b/src/__tests__/components/CatalogSelectionBar.test.tsx new file mode 100644 index 00000000..f9990ee9 --- /dev/null +++ b/src/__tests__/components/CatalogSelectionBar.test.tsx @@ -0,0 +1,94 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'react'; +import CatalogSelectionBar, { + SELECTION_BAR_STRING_KEYS, +} from '../../components/CatalogSelectionBar'; + +/** Each key resolving to itself, but the summary resolving to a template its counts fill in. */ +const STRINGS = { + ...Object.fromEntries(SELECTION_BAR_STRING_KEYS.map((k) => [k, k])), + '%interlinearizer_analysisCatalog_checkedSummary%': '{count} checked, {usageCount} uses', +}; + +/** The bar with every callback stubbed and nothing checked unless a test says otherwise. */ +function renderBar(overrides: Partial> = {}) { + const props = { + count: 0, + usageCount: 0, + allChecked: false, + onCheckAll: jest.fn(), + onClearGloss: jest.fn(), + onDelete: jest.fn(), + localizedStrings: STRINGS, + ...overrides, + }; + render(); + return props; +} + +describe('CatalogSelectionBar', () => { + it('offers only the control checking every row while nothing is checked', () => { + renderBar(); + + expect(screen.getByTestId('catalog-check-all')).toBeInTheDocument(); + expect(screen.queryByTestId('catalog-selection-summary')).not.toBeInTheDocument(); + expect(screen.queryByTestId('catalog-selection-delete')).not.toBeInTheDocument(); + }); + + it('states how many rows are checked and how many uses they have', () => { + renderBar({ count: 2, usageCount: 7 }); + + expect(screen.getByTestId('catalog-selection-summary')).toHaveTextContent('2 checked, 7 uses'); + }); + + it('asks for every row to be checked when not all are', async () => { + const { onCheckAll } = renderBar({ count: 1 }); + + await userEvent.click(screen.getByTestId('catalog-check-all')); + + expect(onCheckAll).toHaveBeenCalledWith(true); + }); + + it('asks for every row to be unchecked when all are', async () => { + const { onCheckAll } = renderBar({ count: 3, allChecked: true }); + + await userEvent.click(screen.getByTestId('catalog-check-all')); + + expect(onCheckAll).toHaveBeenCalledWith(false); + }); + + it('clears the checked rows’ glosses', async () => { + const { onClearGloss } = renderBar({ count: 2 }); + + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + expect(onClearGloss).toHaveBeenCalled(); + }); + + it('deletes the checked rows', async () => { + const { onDelete } = renderBar({ count: 2 }); + + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + expect(onDelete).toHaveBeenCalled(); + }); + + it('opens a merge of the checked rows when they can be merged', async () => { + const onMerge = jest.fn(); + renderBar({ count: 2, onMerge }); + + await userEvent.click(screen.getByTestId('catalog-selection-merge')); + + expect(onMerge).toHaveBeenCalled(); + }); + + it('offers no merge when the checked rows cannot be merged', () => { + renderBar({ count: 2 }); + + expect(screen.queryByTestId('catalog-selection-merge')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index d0cc0b2c..1ae451d1 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -5340,6 +5340,53 @@ describe('undo and redo', () => { ); }); + it('announces an undone deletion of checked catalog rows', async () => { + // The deletion raises an undo notification, which the undo takes down. + jest.mocked(papi.notifications.dismiss).mockResolvedValue(undefined); + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-check')); + await userEvent.click(screen.getByTestId('catalog-selection-delete')); + + await act(async () => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: '%interlinearizer_undone_catalogDeleteChecked%' }), + ); + }); + + it('announces an undone gloss clear across checked catalog rows', async () => { + // Clearing this gloss-only analysis deletes it, which raises an undo notification the undo + // takes down. + jest.mocked(papi.notifications.dismiss).mockResolvedValue(undefined); + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-check')); + await userEvent.click(screen.getByTestId('catalog-selection-clear-gloss')); + + await act(async () => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: '%interlinearizer_undone_catalogClearGlossChecked%' }), + ); + }); + it('scrolls the open catalog to the row an undone catalog edit acted on', async () => { const scrollIntoView = jest.fn(); // jsdom implements no scrollIntoView for the row to call. diff --git a/src/__tests__/hooks/useDraftDiscardGate.test.ts b/src/__tests__/hooks/useDraftDiscardGate.test.ts new file mode 100644 index 00000000..ca20a533 --- /dev/null +++ b/src/__tests__/hooks/useDraftDiscardGate.test.ts @@ -0,0 +1,145 @@ +/// + +import { act, renderHook } from '@testing-library/react'; +import useDraftDiscardGate from '../../hooks/useDraftDiscardGate'; + +/** + * Renders the gate over a set of rows holding unsaved drafts, which a test can change between steps + * as a save or cancel beneath the ask would. + */ +function renderGate(initialDrafts: readonly string[]) { + const discardDraft = jest.fn(); + const { result, rerender } = renderHook( + (drafts: readonly string[]) => + useDraftDiscardGate<'delete'>((analysisId) => drafts.includes(analysisId), discardDraft), + { initialProps: initialDrafts }, + ); + return { + result, + discardDraft, + holdDrafts: (drafts: readonly string[]) => act(() => rerender(drafts)), + }; +} + +describe('useDraftDiscardGate', () => { + it('runs at once when no row it would drop holds an unsaved draft', () => { + const { result } = renderGate(['other']); + const run = jest.fn(); + + act(() => result.current.request('delete', () => ['a', 'b'], run)); + + expect(run).toHaveBeenCalledTimes(1); + expect(result.current.asking).toBeUndefined(); + }); + + it('asks about the first draft the action would drop instead of running', () => { + const { result } = renderGate(['b', 'c']); + const run = jest.fn(); + + act(() => result.current.request('delete', () => ['a', 'b', 'c'], run)); + + expect(run).not.toHaveBeenCalled(); + expect(result.current.asking).toEqual({ action: 'delete', analysisId: 'b' }); + }); + + it('discards the draft asked about once the reader agrees', () => { + const { result, discardDraft } = renderGate(['a']); + act(() => result.current.request('delete', () => ['a'], jest.fn())); + + act(() => result.current.confirm()); + + expect(discardDraft).toHaveBeenCalledWith('a'); + }); + + it('asks about the next draft once one is given up', () => { + const { result } = renderGate(['a', 'b']); + const run = jest.fn(); + act(() => result.current.request('delete', () => ['a', 'b'], run)); + + act(() => result.current.confirm()); + + expect(run).not.toHaveBeenCalled(); + expect(result.current.asking).toEqual({ action: 'delete', analysisId: 'b' }); + }); + + it('runs once every draft it would drop has been given up', () => { + const { result } = renderGate(['a', 'b']); + const run = jest.fn(); + act(() => result.current.request('delete', () => ['a', 'b'], run)); + act(() => result.current.confirm()); + + act(() => result.current.confirm()); + + expect(run).toHaveBeenCalledTimes(1); + expect(result.current.asking).toBeUndefined(); + }); + + it('skips a draft saved while an earlier one was being asked about', () => { + const { result, holdDrafts } = renderGate(['a', 'b']); + const run = jest.fn(); + act(() => result.current.request('delete', () => ['a', 'b'], run)); + holdDrafts(['a']); + + act(() => result.current.confirm()); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it('asks about a draft an edit made while asking adds to the rows the action would drop', () => { + const { result } = renderGate(['a', 'b']); + const run = jest.fn(); + let dropped = ['a']; + act(() => result.current.request('delete', () => dropped, run)); + dropped = ['a', 'b']; + + act(() => result.current.confirm()); + + expect(run).not.toHaveBeenCalled(); + expect(result.current.asking).toEqual({ action: 'delete', analysisId: 'b' }); + }); + + it('runs on the rows the action would drop as last read', () => { + const { result } = renderGate(['a']); + const run = jest.fn(); + let dropped = ['a']; + act(() => result.current.request('delete', () => dropped, run)); + dropped = ['a', 'c']; + + act(() => result.current.confirm()); + + expect(run).toHaveBeenCalledWith(['a', 'c']); + }); + + it('keeps a draft given up when an edit while asking takes its row out of the action', () => { + const { result, discardDraft } = renderGate(['a']); + let dropped = ['a']; + act(() => result.current.request('delete', () => dropped, jest.fn())); + dropped = []; + + act(() => result.current.confirm()); + + expect(discardDraft).not.toHaveBeenCalled(); + }); + + it('keeps a draft given up for an action the reader declines at a later ask', () => { + const { result, discardDraft } = renderGate(['a', 'b']); + act(() => result.current.request('delete', () => ['a', 'b'], jest.fn())); + act(() => result.current.confirm()); + + act(() => result.current.cancel()); + + expect(discardDraft).not.toHaveBeenCalled(); + }); + + it('abandons the action and keeps the draft when the reader declines', () => { + const { result, discardDraft } = renderGate(['a']); + const run = jest.fn(); + act(() => result.current.request('delete', () => ['a'], run)); + + act(() => result.current.cancel()); + + expect(run).not.toHaveBeenCalled(); + expect(discardDraft).not.toHaveBeenCalled(); + expect(result.current.asking).toBeUndefined(); + }); +}); diff --git a/src/__tests__/hooks/useRowSelection.test.ts b/src/__tests__/hooks/useRowSelection.test.ts new file mode 100644 index 00000000..3fd074ba --- /dev/null +++ b/src/__tests__/hooks/useRowSelection.test.ts @@ -0,0 +1,93 @@ +/// + +import { act, renderHook } from '@testing-library/react'; +import useRowSelection from '../../hooks/useRowSelection'; + +/** Rows for each of `analysisIds`, in order. */ +function rowsOf(...analysisIds: string[]) { + return analysisIds.map((analysisId) => ({ analysisId })); +} + +/** Renders the hook over `initial`, rerendering it over whichever rows a step lists. */ +function renderSelection(initial: readonly { analysisId: string }[]) { + const { result, rerender } = renderHook( + (rows: readonly { analysisId: string }[]) => useRowSelection(rows), + { initialProps: initial }, + ); + return { + result, + listRows: (rows: readonly { analysisId: string }[]) => act(() => rerender(rows)), + }; +} + +describe('useRowSelection', () => { + it('starts with nothing checked', () => { + const { result } = renderSelection(rowsOf('a', 'b')); + + expect([...result.current.checkedIds]).toEqual([]); + }); + + it('checks a row', () => { + const { result } = renderSelection(rowsOf('a', 'b')); + + act(() => result.current.setChecked('b', true)); + + expect([...result.current.checkedIds]).toEqual(['b']); + }); + + it('takes an unchecked row back out of the checked rows', () => { + const { result } = renderSelection(rowsOf('a', 'b')); + act(() => result.current.setChecked('a', true)); + act(() => result.current.setChecked('b', true)); + + act(() => result.current.setChecked('a', false)); + + expect([...result.current.checkedIds]).toEqual(['b']); + }); + + it('checks every listed row at once', () => { + const { result } = renderSelection(rowsOf('a', 'b', 'c')); + + act(() => result.current.setAllChecked(true)); + + expect([...result.current.checkedIds]).toEqual(['a', 'b', 'c']); + }); + + it('unchecks every row at once', () => { + const { result } = renderSelection(rowsOf('a', 'b')); + act(() => result.current.setAllChecked(true)); + + act(() => result.current.setAllChecked(false)); + + expect([...result.current.checkedIds]).toEqual([]); + }); + + it('lets go of a checked row the listing stops holding', () => { + const { result, listRows } = renderSelection(rowsOf('a', 'b')); + act(() => result.current.setAllChecked(true)); + + listRows(rowsOf('b')); + + expect([...result.current.checkedIds]).toEqual(['b']); + }); + + it('leaves a let-go row unchecked once the listing holds it again', () => { + const { result, listRows } = renderSelection(rowsOf('a', 'b')); + act(() => result.current.setAllChecked(true)); + listRows(rowsOf('b')); + + listRows(rowsOf('a', 'b')); + + expect([...result.current.checkedIds]).toEqual(['b']); + }); + + it('keeps the same checked set while the listing still holds every checked row', () => { + const { result, listRows } = renderSelection(rowsOf('a', 'b')); + act(() => result.current.setChecked('a', true)); + const before = result.current.checkedIds; + + listRows(rowsOf('a', 'b', 'c')); + + expect(result.current.checkedIds).toBe(before); + }); +}); diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts index e91504ff..47a859bb 100644 --- a/src/__tests__/store/analysisSlice.test.ts +++ b/src/__tests__/store/analysisSlice.test.ts @@ -18,9 +18,10 @@ import { createAnalysisStore, type AnalysisStore } from '../../store'; import { approveAnalysisForToken, approvePhrase, + clearAnalysisGlosses, confirmAnalysisMorphemes, createPhrase, - deleteAnalysis, + deleteAnalyses, deleteMorphemes, discardStaleAnalysis, deletePhrase, @@ -34,6 +35,7 @@ import { selectApprovedGloss, selectApprovedMorphemes, selectCatalogRows, + selectEmptiedByGlossClear, selectMorphemePayloadIsSolelyOwned, selectMorphemeResetLosesAnnotation, selectPendingPhraseLinks, @@ -5396,11 +5398,11 @@ describe('analysis-keyed reducers', () => { }); }); - describe('deleteAnalysis', () => { + describe('deleteAnalyses', () => { it('removes the payload and every link to it', () => { const store = makeSharedStore(); - store.dispatch(deleteAnalysis({ analysisId: 'ta-shared' })); + store.dispatch(deleteAnalyses({ analysisIds: ['ta-shared'] })); const { tokenAnalyses, tokenAnalysisLinks } = store.getState().analysis.analysis; expect(tokenAnalyses).toHaveLength(0); @@ -5410,7 +5412,7 @@ describe('analysis-keyed reducers', () => { it('leaves the affected tokens reading as blank', () => { const store = makeSharedStore(); - store.dispatch(deleteAnalysis({ analysisId: 'ta-shared' })); + store.dispatch(deleteAnalyses({ analysisIds: ['ta-shared'] })); const state = store.getState().analysis; expect(selectApprovedGloss(state, 'tok-1')).toBe(''); @@ -5421,7 +5423,7 @@ describe('analysis-keyed reducers', () => { const store = makeSharedStore(); store.dispatch(writeGloss('tok-3', 'word', 'second')); - store.dispatch(deleteAnalysis({ analysisId: 'ta-shared' })); + store.dispatch(deleteAnalyses({ analysisIds: ['ta-shared'] })); expect(selectApprovedGloss(store.getState().analysis, 'tok-3')).toBe('second'); }); @@ -5447,12 +5449,94 @@ describe('analysis-keyed reducers', () => { }, }); - store.dispatch(deleteAnalysis({ analysisId: 'ta-unused' })); + store.dispatch(deleteAnalyses({ analysisIds: ['ta-unused'] })); const { tokenAnalyses, tokenAnalysisLinks } = store.getState().analysis.analysis; expect(tokenAnalyses).toHaveLength(0); expect(tokenAnalysisLinks).toHaveLength(0); }); + + it('removes every listed payload and every link to them, leaving the rest', () => { + const store = makeSharedStore(); + store.dispatch(writeGloss('tok-3', 'other', 'second')); + store.dispatch(writeGloss('tok-4', 'third', 'kept')); + const deletedIds = store + .getState() + .analysis.analysis.tokenAnalyses.filter((ta) => ta.surfaceText !== 'third') + .map((ta) => ta.id); + + store.dispatch(deleteAnalyses({ analysisIds: deletedIds })); + + const { tokenAnalyses, tokenAnalysisLinks } = store.getState().analysis.analysis; + expect(tokenAnalyses.map((ta) => ta.surfaceText)).toEqual(['third']); + expect(tokenAnalysisLinks.map((l) => l.token.tokenRef)).toEqual(['tok-4']); + }); + }); + + describe('clearAnalysisGlosses', () => { + /** A record of `surfaceText` glossed in both the active language and French. */ + function glossedTwice(id: string, surfaceText: string): TokenAnalysis { + return { ...FIXTURE_STAMPS, id, surfaceText, gloss: { und: id, fr: id } }; + } + + function storeOf(tokenAnalyses: TokenAnalysis[]) { + return createAnalysisStore({ + analysis: { + analysis: { ...emptyAnalysis(), tokenAnalyses }, + analysisLanguage: 'und', + }, + }); + } + + it("clears the active language's gloss on every listed record, leaving the rest", () => { + const store = storeOf([ + glossedTwice('ta-1', 'one'), + glossedTwice('ta-2', 'two'), + glossedTwice('ta-3', 'three'), + ]); + + store.dispatch(clearAnalysisGlosses({ analysisIds: ['ta-1', 'ta-3'] })); + + expect(store.getState().analysis.analysis.tokenAnalyses.map((ta) => ta.gloss)).toEqual([ + { fr: 'ta-1' }, + { und: 'ta-2', fr: 'ta-2' }, + { fr: 'ta-3' }, + ]); + }); + }); + + describe('selectEmptiedByGlossClear', () => { + it('names the listed records clearing the gloss would leave with nothing', () => { + const store = createAnalysisStore({ + analysis: { + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-gloss', surfaceText: 'one', gloss: { und: 'a' } }, + { + ...FIXTURE_STAMPS, + id: 'ta-french', + surfaceText: 'two', + gloss: { und: 'b', fr: 'b' }, + }, + { + ...FIXTURE_STAMPS, + id: 'ta-split', + surfaceText: 'three', + gloss: { und: 'c' }, + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'three', writingSystem: 'el' }], + }, + { ...FIXTURE_STAMPS, id: 'ta-unlisted', surfaceText: 'four', gloss: { und: 'd' } }, + ], + }, + analysisLanguage: 'und', + }, + }); + + expect( + selectEmptiedByGlossClear(store.getState().analysis, ['ta-gloss', 'ta-french', 'ta-split']), + ).toEqual(['ta-gloss']); + }); }); describe('mergeAnalysesInto', () => { diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx index ea05e182..a4ada396 100644 --- a/src/components/AnalysisCatalogPanel.tsx +++ b/src/components/AnalysisCatalogPanel.tsx @@ -7,8 +7,10 @@ import { useCallback, useEffect, useImperativeHandle, useMemo, useState, type Re import { useAnalysisDeletionOutcome, useAnalysisLanguage, + useAnalysisReadOnly, useAnalysisRowDispatch, useCatalogRows, + useGlossClearReclaims, useReportGlossEditing, useStaleLocationDispatch, useStaleLocationReclaims, @@ -25,9 +27,13 @@ import CatalogMergeNotice, { type StrandedDraftNotice, } from './CatalogMergeNotice'; import CatalogQueryControls, { QUERY_CONTROL_STRING_KEYS } from './CatalogQueryControls'; +import CatalogList from './CatalogList'; import CatalogRowView, { ROW_STRING_KEYS } from './CatalogRowView'; +import CatalogSelectionBar, { SELECTION_BAR_STRING_KEYS } from './CatalogSelectionBar'; import SidePanelTabs, { SIDE_PANEL_TAB_STRING_KEYS } from './SidePanelTabs'; import { useInterlinearNav } from './InterlinearNavContext'; +import useDraftDiscardGate from '../hooks/useDraftDiscardGate'; +import useRowSelection from '../hooks/useRowSelection'; import useRowWindow from '../hooks/useRowWindow'; import type { StepSummary } from '../hooks/useDraftProject'; import { normalizeSurfaceForm } from '../utils/analysis-identity'; @@ -54,6 +60,12 @@ const RUN_UNGROUPED = (action: () => T): T => action(); /** Tells no one about an edit, having no history to undo it from. */ const ANNOUNCE_NOTHING = () => {}; +function totalUsageCount(rows: readonly CatalogRow[], analysisIds: ReadonlySet): number { + return rows + .filter((r) => analysisIds.has(r.analysisId)) + .reduce((sum, r) => sum + r.usageCount, 0); +} + /** Summarizes a catalog step on the row `analysisId` names, given where the step left that row. */ function catalogStepSummary( kind: 'catalogEdit' | 'catalogMerge', @@ -66,6 +78,16 @@ function catalogStepSummary( return { kind, form, analysisId, survivingAnalysisId }; } +/** + * The announcement for a gloss clear that deleted or merged at least one record, phrased for + * whichever it did. + */ +function clearedGlossKey(deletedCount: number, mergedCount: number) { + if (mergedCount === 0) return '%interlinearizer_analysisCatalog_clearedGlossDeleted%'; + if (deletedCount === 0) return '%interlinearizer_analysisCatalog_clearedGlossMerged%'; + return '%interlinearizer_analysisCatalog_clearedGlossDeletedAndMerged%'; +} + /** * Localized string keys the panel needs, the rows' among them so the list resolves once rather than * once per analysis. Hoisted to module scope so the reference passed to `useLocalizedStrings` is @@ -73,12 +95,17 @@ function catalogStepSummary( * state every render. */ const STRING_KEYS = [ + '%interlinearizer_analysisCatalog_clearedGlossDeleted%', + '%interlinearizer_analysisCatalog_clearedGlossDeletedAndMerged%', + '%interlinearizer_analysisCatalog_clearedGlossMerged%', '%interlinearizer_analysisCatalog_close%', + '%interlinearizer_analysisCatalog_deletedChecked%', '%interlinearizer_analysisCatalog_empty%', '%interlinearizer_analysisCatalog_usageCountInBook%', '%interlinearizer_analysisCatalog_noMatches%', ...QUERY_CONTROL_STRING_KEYS, ...ROW_STRING_KEYS, + ...SELECTION_BAR_STRING_KEYS, ...MERGE_NOTICE_STRING_KEYS, ...MERGE_STRING_KEYS, ...DELETION_ANNOUNCEMENT_STRING_KEYS, @@ -104,32 +131,13 @@ export type AnalysisCatalogPanelHandle = Readonly<{ filterTo: (filters: CatalogFilters) => void; }>; -/** A review of a place an analysis went stale at, held while it waits on the reader's consent. */ -type StaleReview = - | { - kind: 'discard'; - analysisId: string; - /** The stale place being given up. */ - tokenRef: string; - } - | { - kind: 'reapply'; - analysisId: string; - /** The stale place the analysis leaves. */ - staleTokenRef: string; - /** The token the analysis moves onto. */ - tokenRef: string; - /** The current text of the token the analysis moves onto. */ - surfaceText: string; - }; - -/** What each ask to discard a draft names it as given up for. */ -const DISCARD_ACTION = { - delete: 'delete', - merge: 'merge', - 'merge-confirm': 'merge', - stale: 'stale', -} as const; +/** A merge picker being opened. */ +type MergeRequest = Readonly<{ + /** The row the picker opens on, which names the form whose analyses it lists. */ + analysisId: string; + /** The rows the picker starts out folding into that one. */ + mergedIds: readonly string[]; +}>; /** Props for {@link AnalysisCatalogPanel}. */ type AnalysisCatalogPanelProps = Readonly<{ @@ -403,15 +411,23 @@ export default function AnalysisCatalogPanel({ const rowDispatch = useAnalysisRowDispatch(); const readDeletionOutcome = useAnalysisDeletionOutcome(); + const readOnly = useAnalysisReadOnly(); + + const { checkedIds, setChecked, setAllChecked } = useRowSelection(rows); + + /** How many uses the checked rows have between them, which is what a bulk action reaches. */ + const checkedUsageCount = useMemo( + () => totalUsageCount(catalogRows, checkedIds), + [catalogRows, checkedIds], + ); /** - * The row whose merge picker is open, or `undefined` when none is. It names the form whose - * analyses the picker lists and the source it opens on. + * The merge picker that is open, or `undefined` when none is. * * Held as ids rather than as rows, so a listing that turns over beneath an open modal cannot * leave it holding a stale copy of what it is about to act on. */ - const [mergeSourceId, setMergeSourceId] = useState(undefined); + const [mergeRequest, setMergeRequest] = useState(undefined); /** * The breakdown draft each row is holding, keyed by analysis id, for the rows holding one. Kept @@ -611,41 +627,31 @@ export default function AnalysisCatalogPanel({ ); /** - * The edit a row is waiting to make once the reader agrees to lose the breakdown they typed - * against it, or `undefined` when none is waiting. - * - * Merging and deleting both drop the record a draft is keyed to, which takes the draft with it — - * so like closing, they ask first. So does a stale-place review, for every record it would drop. - * - * A merge asks once for the row it is opened from, and again at confirmation for every other - * record it would fold in — a draft apiece, none of them covered by the opening ask. + * Holds back an edit until the reader agrees to lose each breakdown they typed against a row it + * would drop, a dropped record taking its draft with it. */ - const [discardingFor, setDiscardingFor] = useState< - | { kind: 'merge' | 'delete'; analysisId: string } - | { - kind: 'merge-confirm'; - analysisId: string; - /** The merge the ask is standing between, held so agreeing commits what was settled. */ - merge: { - survivorAnalysisId: string; - mergedAnalysisIds: readonly string[]; - content: MergedContentDraft; - surfaceText: string; - }; - /** The records this merge's earlier asks settled, which it does not ask about again. */ - confirmedIds: readonly string[]; - } - | { - kind: 'stale'; - /** The record whose breakdown the ask names, one of those the review would drop. */ - analysisId: string; - /** The review the ask is standing between, held so agreeing runs it. */ - review: StaleReview; - /** The records this review's earlier asks settled, which it does not ask about again. */ - confirmedIds: readonly string[]; - } - | undefined - >(undefined); + const discardGate = useDraftDiscardGate<'delete' | 'clearGloss' | 'merge' | 'stale'>( + rowHasUnsavedBreakdown, + discardBreakdownDraft, + ); + const requestDiscard = discardGate.request; + + /** + * Asks about the unsaved breakdowns on the records `readDroppedIds` names, then runs `run` with + * them, dropping any untouched draft with its record rather than reporting it stranded. + */ + const requestDropping = useCallback( + ( + action: 'clearGloss' | 'stale', + readDroppedIds: () => readonly string[], + run: (droppedIds: readonly string[]) => void, + ) => + requestDiscard(action, readDroppedIds, (droppedIds) => { + droppedIds.forEach(discardBreakdownDraft); + run(droppedIds); + }), + [discardBreakdownDraft, requestDiscard], + ); /** Deletes the analysis `analysisId` names, announcing what the deletion did. */ const deleteAnalysis = useCallback( @@ -658,7 +664,7 @@ export default function AnalysisCatalogPanel({ // asked for it. discardBreakdownDraft(analysisId); asOneStep( - () => rowDispatch.deleteAnalysis(analysisId), + () => rowDispatch.deleteAnalyses([analysisId]), outcome.usageCount === 0 ? { kind: 'catalogDeleteUnused', form, analysisId } : { kind: 'catalogDelete', form, analysisId, count: outcome.usageCount }, @@ -681,19 +687,102 @@ export default function AnalysisCatalogPanel({ ); const handleDeleteRequest = useCallback( - (analysisId: string) => { - if (rowHasUnsavedBreakdown(analysisId)) setDiscardingFor({ kind: 'delete', analysisId }); - else deleteAnalysis(analysisId); + (analysisId: string) => + requestDiscard( + 'delete', + () => [analysisId], + () => deleteAnalysis(analysisId), + ), + [deleteAnalysis, requestDiscard], + ); + + /** Deletes the analyses `analysisIds` names as one step, announcing how many uses they had. */ + const deleteChecked = useCallback( + (analysisIds: readonly string[]) => { + const count = analysisIds.length; + const usageCount = totalUsageCount(catalogRows, new Set(analysisIds)); + // Cleared before the records go, so their removal is not reported back as stranding them. + analysisIds.forEach(discardBreakdownDraft); + asOneStep(() => rowDispatch.deleteAnalyses(analysisIds), { + kind: 'catalogDeleteChecked', + count, + usageCount, + }); + setMergeNotice(undefined); + announceUndoable( + formatReplacementString( + localizedStrings['%interlinearizer_analysisCatalog_deletedChecked%'], + { count, usageCount }, + ), + ); }, - [deleteAnalysis, rowHasUnsavedBreakdown], + [ + announceUndoable, + asOneStep, + catalogRows, + discardBreakdownDraft, + localizedStrings, + rowDispatch, + ], ); - const handleMergeRequest = useCallback( - (analysisId: string) => { - if (rowHasUnsavedBreakdown(analysisId)) setDiscardingFor({ kind: 'merge', analysisId }); - else setMergeSourceId(analysisId); + const handleDeleteChecked = useCallback(() => { + const analysisIds = [...checkedIds]; + requestDiscard( + 'delete', + () => analysisIds, + () => deleteChecked(analysisIds), + ); + }, [checkedIds, deleteChecked, requestDiscard]); + + /** + * Clears the glosses on `analysisIds` as one step, announcing how many of the records it removes, + * `reclaimedIds`, it deleted for having nothing else and how many it merged into an identical + * analysis. A clear that removes nothing goes unannounced, the cleared glosses showing in the + * rows. + */ + const clearGlossChecked = useCallback( + (analysisIds: readonly string[], reclaimedIds: readonly string[]) => { + const count = analysisIds.length; + const deletedIds = asOneStep(() => rowDispatch.clearGlosses(analysisIds), { + kind: 'catalogClearGlossChecked', + count, + }); + setMergeNotice(undefined); + const deletedCount = deletedIds.length; + const mergedCount = reclaimedIds.filter((id) => !deletedIds.includes(id)).length; + if (deletedCount === 0 && mergedCount === 0) return; + announceUndoable( + formatReplacementString(localizedStrings[clearedGlossKey(deletedCount, mergedCount)], { + count, + deletedCount, + mergedCount, + usageCount: totalUsageCount(catalogRows, new Set(deletedIds)), + }), + ); }, - [rowHasUnsavedBreakdown], + [announceUndoable, asOneStep, catalogRows, localizedStrings, rowDispatch], + ); + + const readGlossClearReclaims = useGlossClearReclaims(); + + const handleClearGlossChecked = useCallback(() => { + const analysisIds = [...checkedIds]; + requestDropping( + 'clearGloss', + () => readGlossClearReclaims(analysisIds), + (reclaimedIds) => clearGlossChecked(analysisIds, reclaimedIds), + ); + }, [checkedIds, clearGlossChecked, readGlossClearReclaims, requestDropping]); + + const handleMergeRequest = useCallback( + (analysisId: string, mergedIds: readonly string[] = []) => + requestDiscard( + 'merge', + () => [analysisId], + () => setMergeRequest({ analysisId, mergedIds }), + ), + [requestDiscard], ); /** @@ -737,7 +826,7 @@ export default function AnalysisCatalogPanel({ }), (merge) => catalogStepSummary('catalogMerge', surfaceText, survivorAnalysisId, merge), ); - setMergeSourceId(undefined); + setMergeRequest(undefined); // Reported against the record the merge left standing rather than the one it was aimed at: // content matching an unmerged homograph moves the survivor, which the reader was warned of. @@ -762,121 +851,45 @@ export default function AnalysisCatalogPanel({ ); /** - * Asks about the next unsaved breakdown a settled merge would drop, or commits it once every one - * has been agreed to — a discard being a decision per draft rather than one taken for all of - * them. + * Commits the merge the panel settled, which names its own survivor and content, once any draft + * on a record it would drop has been asked about. */ - const askOrCommitMerge = useCallback( + const handleMergeConfirm = useCallback( ( - merge: { - survivorAnalysisId: string; - mergedAnalysisIds: readonly string[]; - content: MergedContentDraft; - surfaceText: string; - }, - confirmedIds: readonly string[], - ) => { - const { survivorAnalysisId, mergedAnalysisIds, content, surfaceText } = merge; - const discarding = [survivorAnalysisId, ...mergedAnalysisIds] - .filter((id) => !confirmedIds.includes(id)) - .find(rowHasUnsavedBreakdown); - if (discarding) - setDiscardingFor({ kind: 'merge-confirm', analysisId: discarding, merge, confirmedIds }); - else commitMerge(survivorAnalysisId, mergedAnalysisIds, content, surfaceText); - }, - [commitMerge, rowHasUnsavedBreakdown], + survivorAnalysisId: string, + mergedAnalysisIds: readonly string[], + content: MergedContentDraft, + surfaceText: string, + ) => + requestDiscard( + 'merge', + () => [survivorAnalysisId, ...mergedAnalysisIds], + () => commitMerge(survivorAnalysisId, mergedAnalysisIds, content, surfaceText), + ), + [commitMerge, requestDiscard], ); const staleDispatch = useStaleLocationDispatch(); const readStaleReclaims = useStaleLocationReclaims(); - /** - * Asks about the next unsaved breakdown a stale-place review would drop along with its record, or - * runs the review once every one has been agreed to. - */ - const askOrRunStaleReview = useCallback( - (review: StaleReview, confirmedIds: readonly string[]) => { - const reclaimed = - review.kind === 'discard' - ? readStaleReclaims.discard(review.analysisId, review.tokenRef) - : readStaleReclaims.reapply( - review.analysisId, - review.staleTokenRef, - review.tokenRef, - review.surfaceText, - ); - const discarding = reclaimed - .filter((id) => !confirmedIds.includes(id)) - .find(rowHasUnsavedBreakdown); - if (discarding) { - setDiscardingFor({ kind: 'stale', analysisId: discarding, review, confirmedIds }); - return; - } - - // An untouched draft goes with its record too, unasked, rather than being reported stranded. - reclaimed.forEach(discardBreakdownDraft); - if (review.kind === 'discard') staleDispatch.discard(review.analysisId, review.tokenRef); - else - staleDispatch.reapply( - review.analysisId, - review.staleTokenRef, - review.tokenRef, - review.surfaceText, - ); - }, - [discardBreakdownDraft, readStaleReclaims, rowHasUnsavedBreakdown, staleDispatch], - ); - const handleStaleDiscard = useCallback( (analysisId: string, location: CatalogUsage) => - askOrRunStaleReview({ kind: 'discard', analysisId, tokenRef: location.tokenRef }, []), - [askOrRunStaleReview], + requestDropping( + 'stale', + () => readStaleReclaims.discard(analysisId, location.tokenRef), + () => staleDispatch.discard(analysisId, location.tokenRef), + ), + [readStaleReclaims, requestDropping, staleDispatch], ); const handleStaleReapply = useCallback( (analysisId: string, location: CatalogUsage, tokenRef: string, surfaceText: string) => - askOrRunStaleReview( - { kind: 'reapply', analysisId, staleTokenRef: location.tokenRef, tokenRef, surfaceText }, - [], + requestDropping( + 'stale', + () => readStaleReclaims.reapply(analysisId, location.tokenRef, tokenRef, surfaceText), + () => staleDispatch.reapply(analysisId, location.tokenRef, tokenRef, surfaceText), ), - [askOrRunStaleReview], - ); - - /** - * Gives up the draft, leaving the edit itself still to be confirmed — except for a settled merge - * or a stale-place review, which the ask was the last thing standing between. - */ - const handleDiscardConfirm = useCallback(() => { - /* v8 ignore next -- unreachable: the modal that calls this mounts only on a set ask */ - if (!discardingFor) return; - const { kind, analysisId } = discardingFor; - discardBreakdownDraft(analysisId); - setDiscardingFor(undefined); - if (kind === 'delete') deleteAnalysis(analysisId); - else if (kind === 'merge-confirm') - askOrCommitMerge(discardingFor.merge, [...discardingFor.confirmedIds, analysisId]); - else if (kind === 'stale') - askOrRunStaleReview(discardingFor.review, [...discardingFor.confirmedIds, analysisId]); - else setMergeSourceId(analysisId); - }, [askOrCommitMerge, askOrRunStaleReview, deleteAnalysis, discardingFor, discardBreakdownDraft]); - - /** - * Commits the merge the panel settled, which names its own survivor and content, once any draft - * on a record it would drop has been asked about. - */ - const handleMergeConfirm = useCallback( - ( - survivorAnalysisId: string, - mergedAnalysisIds: readonly string[], - content: MergedContentDraft, - surfaceText: string, - ) => { - askOrCommitMerge( - { survivorAnalysisId, mergedAnalysisIds, content, surfaceText }, - /* confirmedIds */ [], - ); - }, - [askOrCommitMerge], + [readStaleReclaims, requestDropping, staleDispatch], ); /** @@ -915,12 +928,31 @@ export default function AnalysisCatalogPanel({ * lingering. */ const openMerge = useMemo(() => { - const openedFrom = catalogRows.find((r) => r.analysisId === mergeSourceId); + const openedFrom = catalogRows.find((r) => r.analysisId === mergeRequest?.analysisId); if (!openedFrom) return undefined; /* v8 ignore next -- unreachable: every row is filed, so a resolved one is in its own bucket */ const candidates = homographRowsByForm.get(normalizeSurfaceForm(openedFrom.surfaceText)) ?? []; - return candidates.length > 1 ? { openedFrom, candidates } : undefined; - }, [catalogRows, homographRowsByForm, mergeSourceId]); + return candidates.length > 1 + ? { openedFrom, candidates, mergedIds: mergeRequest?.mergedIds } + : undefined; + }, [catalogRows, homographRowsByForm, mergeRequest]); + + /** + * Opens a merge of the checked rows into the most used of them, or `undefined` unless at least + * two are checked and all of them are homographs. A merge only reassigns tokens within one form. + */ + const mergeChecked = useMemo(() => { + const checkedRows = rows.filter((r) => checkedIds.has(r.analysisId)); + if (checkedRows.length < 2) return undefined; + const form = normalizeSurfaceForm(checkedRows[0].surfaceText); + if (checkedRows.some((r) => normalizeSurfaceForm(r.surfaceText) !== form)) return undefined; + const [survivor, ...merged] = checkedRows.toSorted((a, b) => b.usageCount - a.usageCount); + return () => + handleMergeRequest( + survivor.analysisId, + merged.map((r) => r.analysisId), + ); + }, [rows, checkedIds, handleMergeRequest]); return ( // The panel sits beside the interlinear view rather than within it, so the row tooltips have no @@ -978,6 +1010,19 @@ export default function AnalysisCatalogPanel({ /> )} + {!readOnly && rows.length > 0 && ( + + )} + {strandedDraft && ( ) : ( -
      + {windowRows.map((row) => ( ))} - {/* - Sits after the last mounted row, so reaching it means the reader has scrolled to the - end of what is mounted rather than to the end of the listing. A list item rather than a - bare div, since a `ul` may hold nothing else. - */} -
    • -
    + )} {/* @@ -1053,7 +1091,8 @@ export default function AnalysisCatalogPanel({ candidates={openMerge.candidates} initialSurvivorId={openMerge.openedFrom.analysisId} localizedStrings={localizedStrings} - onCancel={() => setMergeSourceId(undefined)} + initialMergedIds={openMerge.mergedIds} + onCancel={() => setMergeRequest(undefined)} onConfirm={handleMergeConfirm} sourceLanguageTag={sourceLanguageTag} surfaceText={openMerge.openedFrom.surfaceText} @@ -1074,12 +1113,12 @@ export default function AnalysisCatalogPanel({ /> )} - {discardingFor && rowHasUnsavedBreakdown(discardingFor.analysisId) && ( + {discardGate.asking && rowHasUnsavedBreakdown(discardGate.asking.analysisId) && ( setDiscardingFor(undefined)} - onConfirm={handleDiscardConfirm} + onCancel={discardGate.cancel} + onConfirm={discardGate.confirm} /> )} diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index 7bf22b20..2ddc9de3 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -22,7 +22,8 @@ import analysisReducer, { approveAnalysisForToken, confirmAnalysisMorphemes, createPhrase, - deleteAnalysis, + clearAnalysisGlosses, + deleteAnalyses, discardStaleAnalysis, discardStaleFreeTranslation, keepStaleFreeTranslation, @@ -33,6 +34,7 @@ import analysisReducer, { selectAnalysis, selectAnalysisLanguage, selectAnalysisDeletionOutcome, + selectEmptiedByGlossClear, selectApprovedGloss, selectApprovedMorphemes, selectCatalogRows, @@ -579,6 +581,11 @@ export type AnalysisRowDispatch = { * blank value clears it. */ writeGloss: (analysisId: string, value: string) => AnalysisEditOutcome; + /** + * Clears the gloss on several records at once, as a blank {@link AnalysisRowDispatch.writeGloss} + * does on one, returning the records it left with nothing and so deleted. + */ + clearGlosses: (analysisIds: readonly string[]) => readonly string[]; /** * Re-segments the record's morpheme breakdown for every token linked to it, keeping the glosses * and lexicon links on every form the re-split does not drop. @@ -595,10 +602,10 @@ export type AnalysisRowDispatch = { value: string, ) => AnalysisEditOutcome; /** - * Removes the record and every link to it, leaving its tokens on whatever the suggestion pool - * still offers. See {@link useAnalysisDeletionOutcome} for what it will cost. + * Removes the records and every link to them, leaving their tokens on whatever the suggestion + * pool still offers. See {@link useAnalysisDeletionOutcome} for what deleting one will cost. */ - deleteAnalysis: (analysisId: string) => void; + deleteAnalyses: (analysisIds: readonly string[]) => void; /** * Folds several records into one, writing the content the merge settled onto the survivor. * Reports where it left the survivor, which a merge whose content converges on an unmerged record @@ -646,6 +653,16 @@ export function useAnalysisRowDispatch(): AnalysisRowDispatch { [writeAndReport], ); + const handleClearGlosses = useCallback( + (analysisIds: readonly string[]) => { + const emptied = selectEmptiedByGlossClear(store.getState().analysis, analysisIds); + dispatch(clearAnalysisGlosses({ analysisIds })); + save(); + return emptied; + }, + [dispatch, save, store], + ); + const handleWriteMorphemes = useCallback( (analysisId: string, forms: readonly string[], writingSystem: string) => writeAndReport(analysisId, writeAnalysisMorphemes({ analysisId, forms, writingSystem })), @@ -658,9 +675,9 @@ export function useAnalysisRowDispatch(): AnalysisRowDispatch { [writeAndReport], ); - const handleDelete = useCallback( - (analysisId: string) => { - dispatch(deleteAnalysis({ analysisId })); + const handleDeleteAnalyses = useCallback( + (analysisIds: readonly string[]) => { + dispatch(deleteAnalyses({ analysisIds })); save(); }, [dispatch, save], @@ -678,16 +695,18 @@ export function useAnalysisRowDispatch(): AnalysisRowDispatch { return useMemo( () => ({ writeGloss: handleWriteGloss, + clearGlosses: handleClearGlosses, writeMorphemes: handleWriteMorphemes, writeMorphemeGloss: handleWriteMorphemeGloss, - deleteAnalysis: handleDelete, + deleteAnalyses: handleDeleteAnalyses, mergeAll: handleMergeAll, }), [ handleWriteGloss, + handleClearGlosses, handleWriteMorphemes, handleWriteMorphemeGloss, - handleDelete, + handleDeleteAnalyses, handleMergeAll, ], ); @@ -740,6 +759,23 @@ export function useStaleLocationDispatch(): StaleLocationDispatch { return useMemo(() => ({ discard, reapply }), [discard, reapply]); } +/** Returns a stable getter for the ids of the records a write would remove, without committing it. */ +function useReclaimedBy(): (action: Parameters[1]) => string[] { + const store = useStore(); + + // Runs the write through the reducer itself, so this cannot disagree with it. + return useCallback( + (action: Parameters[1]) => { + const before = store.getState().analysis; + const kept = new Set( + analysisReducer(before, action).analysis.tokenAnalyses.map((ta) => ta.id), + ); + return before.analysis.tokenAnalyses.map((ta) => ta.id).filter((id) => !kept.has(id)); + }, + [store], + ); +} + /** What each {@link StaleLocationDispatch} write would reclaim. */ export type StaleLocationReclaims = { /** Ids of the records that giving up `analysisId`'s stale place at `tokenRef` would reclaim. */ @@ -763,19 +799,7 @@ export type StaleLocationReclaims = { * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useStaleLocationReclaims(): StaleLocationReclaims { - const store = useStore(); - - // Runs the write through the reducer without committing it, so this cannot disagree with it. - const reclaimedBy = useCallback( - (action: Parameters[1]) => { - const before = store.getState().analysis; - const kept = new Set( - analysisReducer(before, action).analysis.tokenAnalyses.map((ta) => ta.id), - ); - return before.analysis.tokenAnalyses.map((ta) => ta.id).filter((id) => !kept.has(id)); - }, - [store], - ); + const reclaimedBy = useReclaimedBy(); const discard = useCallback( (analysisId: string, tokenRef: string) => @@ -792,6 +816,20 @@ export function useStaleLocationReclaims(): StaleLocationReclaims { return useMemo(() => ({ discard, reapply }), [discard, reapply]); } +/** + * Returns a stable getter for the ids of the records clearing the glosses on `analysisIds` would + * remove — left empty, or folded into an identical sibling — as the store stands at the call. + * + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useGlossClearReclaims(): (analysisIds: readonly string[]) => readonly string[] { + const reclaimedBy = useReclaimedBy(); + return useCallback( + (analysisIds: readonly string[]) => reclaimedBy(clearAnalysisGlosses({ analysisIds })), + [reclaimedBy], + ); +} + /** * Returns a stable getter for what deleting a record would do to the tokens that approve it — left * blank, or falling back to a surviving homograph — so the deletion's announcement can name the diff --git a/src/components/CatalogCloseModal.tsx b/src/components/CatalogCloseModal.tsx index 4c744b89..603b9073 100644 --- a/src/components/CatalogCloseModal.tsx +++ b/src/components/CatalogCloseModal.tsx @@ -16,6 +16,8 @@ export const CLOSE_STRING_KEYS = [ '%interlinearizer_analysisCatalog_discardForSwitchConfirm%', '%interlinearizer_analysisCatalog_discardForStalePrompt%', '%interlinearizer_analysisCatalog_discardForStaleConfirm%', + '%interlinearizer_analysisCatalog_discardForClearGlossPrompt%', + '%interlinearizer_analysisCatalog_discardForClearGlossConfirm%', ] as const satisfies `%${string}%`[]; /** Props for {@link CatalogCloseModal}. */ @@ -27,7 +29,7 @@ type CatalogCloseModalProps = Readonly<{ /** Resolved localizations covering at least {@link CLOSE_STRING_KEYS}. */ localizedStrings: LanguageStrings; /** What the draft is being given up for; closing the panel when absent. */ - action?: 'merge' | 'delete' | 'switch' | 'stale'; + action?: 'merge' | 'delete' | 'switch' | 'stale' | 'clearGloss'; }>; /** The prompt and confirm-button keys naming what the draft is being given up for. */ @@ -52,6 +54,10 @@ const ACTION_KEYS = { '%interlinearizer_analysisCatalog_discardForStalePrompt%', '%interlinearizer_analysisCatalog_discardForStaleConfirm%', ], + clearGloss: [ + '%interlinearizer_analysisCatalog_discardForClearGlossPrompt%', + '%interlinearizer_analysisCatalog_discardForClearGlossConfirm%', + ], } as const; /** diff --git a/src/components/CatalogList.tsx b/src/components/CatalogList.tsx new file mode 100644 index 00000000..da478e07 --- /dev/null +++ b/src/components/CatalogList.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +/** Props for {@link CatalogList}. */ +type CatalogListProps = Readonly<{ + /** Receives the element the rows scroll in. */ + scrollRef: (el: HTMLElement | null) => void; + /** Receives the element whose coming into view means the reader reached the last mounted row. */ + sentinelRef: (el: HTMLElement | null) => void; + /** The mounted rows. */ + children: ReactNode; +}>; + +/** + * Lists the catalog's rows on the columns {@link CatalogRowHeader} fills, so the columns of every + * row line up. + */ +export default function CatalogList({ scrollRef, sentinelRef, children }: CatalogListProps) { + return ( +
      + {children} + {/* + Sits after the last mounted row, so reaching it means the reader has scrolled to the end of + what is mounted rather than to the end of the listing. A list item rather than a bare div, + since a `ul` may hold nothing else. + */} +
    • +
    + ); +} diff --git a/src/components/CatalogMergeModal.tsx b/src/components/CatalogMergeModal.tsx index dcdb313c..63d32294 100644 --- a/src/components/CatalogMergeModal.tsx +++ b/src/components/CatalogMergeModal.tsx @@ -121,8 +121,10 @@ type CatalogMergeModalProps = Readonly<{ surfaceText: string; /** Every analysis of the form — the survivor, the ones it may absorb, and the rest. */ candidates: readonly CatalogRow[]; - /** The analysis the panel was opened from, which starts as the survivor. */ + /** The analysis that starts as the survivor. */ initialSurvivorId: string; + /** The analyses the merge starts out folding in, besides the survivor. */ + initialMergedIds?: readonly string[]; /** BCP 47 tag the glosses are read and written under. */ analysisLanguage: string; /** When false, the breakdown fields are not shown, as the view option hides them on the strip. */ @@ -370,6 +372,7 @@ export default function CatalogMergeModal({ surfaceText, candidates, initialSurvivorId, + initialMergedIds, analysisLanguage, showMorphology, sourceLanguageTag, @@ -408,7 +411,7 @@ export default function CatalogMergeModal({ * The analyses the merge would fold in, the survivor excluded — it is always in the merge, so * holding it here would be a second place its membership could be said to change. */ - const [mergedIds, setMergedIds] = useState>(new Set()); + const [mergedIds, setMergedIds] = useState>(() => new Set(initialMergedIds)); /** Records one field's edit, or drops it back to what the merged analyses derive. */ const editField = ( diff --git a/src/components/CatalogRowHeader.tsx b/src/components/CatalogRowHeader.tsx new file mode 100644 index 00000000..02d11298 --- /dev/null +++ b/src/components/CatalogRowHeader.tsx @@ -0,0 +1,172 @@ +import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react'; +import { + Button, + Checkbox, + Tooltip, + TooltipContent, + TooltipTrigger, + useTruncationTooltip, +} from 'platform-bible-react'; +import { formatReplacementString, type LanguageStrings } from 'platform-bible-utils'; +import type { CatalogRow } from '../utils/analysis-query'; +import { resolvedOrEmpty } from '../utils/localized-strings'; + +/** Localized string keys a row header renders. */ +export const ROW_HEADER_STRING_KEYS = [ + '%interlinearizer_analysisCatalog_check%', + '%interlinearizer_analysisCatalog_noGloss%', + '%interlinearizer_analysisCatalog_usageCount%', + '%interlinearizer_analysisCatalog_staleCount%', +] as const satisfies `%${string}%`[]; + +/** Props for {@link CatalogRowHeader}. */ +type CatalogRowHeaderProps = Readonly<{ + /** The analysis the row lists. */ + row: CatalogRow; + /** Finished label for `row.usageCountInBook`, naming the book that count was taken against. */ + usageCountInBookLabel: string; + /** Whether the row's detail is showing. */ + isExpanded: boolean; + /** Shows the row's detail, or hides it. */ + onToggle: () => void; + /** Whether the row is among those a bulk action applies to. */ + isChecked: boolean; + /** + * Adds the row to, or takes it out of, the rows a bulk action applies to. Absent for a read-only + * analysis, which no bulk action applies to. + */ + onCheckedChange?: (analysisId: string, checked: boolean) => void; + /** Resolved localizations covering at least {@link ROW_HEADER_STRING_KEYS}. */ + localizedStrings: LanguageStrings; +}>; + +/** + * The line a catalog row always shows: its check box, and the toggle naming the analysis by its + * surface form, gloss, and usage counts. + */ +export default function CatalogRowHeader({ + row, + usageCountInBookLabel, + isExpanded, + onToggle, + isChecked, + onCheckedChange, + localizedStrings, +}: CatalogRowHeaderProps) { + const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%']; + const staleCountLabel = localizedStrings['%interlinearizer_analysisCatalog_staleCount%']; + + // This is visible cell text, so blanking an unresolved key would empty the gloss column. The em + // dash reads as "no gloss" in any language and stands in until the lookup lands. + const glossLabel = + row.gloss || + resolvedOrEmpty(localizedStrings['%interlinearizer_analysisCatalog_noGloss%']) || + '—'; + + // One tooltip each rather than one for the row: either column may be the clipped one, and a + // tooltip is worth opening only over the text that is actually cut off. + const surfaceTooltip = useTruncationTooltip(); + const glossTooltip = useTruncationTooltip(); + + return ( + // Highlighted here rather than on the button, so the highlight takes in the checkbox too. +
    + {onCheckedChange && ( + onCheckedChange(row.analysisId, checked)} + /> + )} + {/* + Carries no `aria-label`: a name on a button overrides its content, so one here would + announce every row alike and suppress the analysis each lists. + */} + +
    + ); +} diff --git a/src/components/CatalogRowView.tsx b/src/components/CatalogRowView.tsx index b5775d39..bd35a8f0 100644 --- a/src/components/CatalogRowView.tsx +++ b/src/components/CatalogRowView.tsx @@ -1,17 +1,10 @@ -import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react'; -import { - Button, - Tooltip, - TooltipContent, - TooltipTrigger, - useTruncationTooltip, -} from 'platform-bible-react'; +import { Button } from 'platform-bible-react'; import { formatReplacementString, formatScrRef, type LanguageStrings } from 'platform-bible-utils'; import { memo, useCallback, useState } from 'react'; import CatalogRowEditor, { CatalogRowActions, ROW_EDITOR_STRING_KEYS } from './CatalogRowEditor'; +import CatalogRowHeader, { ROW_HEADER_STRING_KEYS } from './CatalogRowHeader'; import CatalogStaleLocations, { STALE_LOCATION_STRING_KEYS } from './CatalogStaleLocations'; import type { CatalogRow, CatalogUsage } from '../utils/analysis-query'; -import { resolvedOrEmpty } from '../utils/localized-strings'; /** * Localized string keys a row renders. Every row asks for the same strings, and subscribing per row @@ -19,11 +12,9 @@ import { resolvedOrEmpty } from '../utils/localized-strings'; * down. */ export const ROW_STRING_KEYS = [ - '%interlinearizer_analysisCatalog_noGloss%', - '%interlinearizer_analysisCatalog_usageCount%', '%interlinearizer_analysisCatalog_noUsages%', '%interlinearizer_analysisCatalog_showAllUsages%', - '%interlinearizer_analysisCatalog_staleCount%', + ...ROW_HEADER_STRING_KEYS, ...ROW_EDITOR_STRING_KEYS, ...STALE_LOCATION_STRING_KEYS, ] as const satisfies `%${string}%`[]; @@ -42,6 +33,13 @@ type CatalogRowViewProps = Readonly<{ usageCountInBookLabel: string; /** Whether this is the row the view was last jumped from. */ isSelected: boolean; + /** Whether this row is among those a bulk action applies to. */ + isChecked: boolean; + /** + * Adds this row to, or takes it out of, the rows a bulk action applies to. Absent for a read-only + * analysis, which no bulk action applies to. + */ + onCheckedChange?: (analysisId: string, checked: boolean) => void; /** Jumps the interlinear view to one of this analysis's usages. */ onUsageSelect: (analysisId: string, usage: CatalogUsage) => void; /** Moves the interlinear view to the verse of a place this analysis went stale at. */ @@ -111,6 +109,8 @@ function CatalogRowView({ row, usageCountInBookLabel, isSelected, + isChecked, + onCheckedChange, onUsageSelect, onStaleSelect, onStaleDiscard, @@ -185,21 +185,6 @@ function CatalogRowView({ const visibleUsages = showsAllUsages ? row.usages : row.usages.slice(0, INLINE_USAGE_LIMIT); const hiddenUsageCount = row.usages.length - visibleUsages.length; - const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%']; - const staleCountLabel = localizedStrings['%interlinearizer_analysisCatalog_staleCount%']; - - // This is visible cell text, so blanking an unresolved key would empty the gloss column. The em - // dash reads as "no gloss" in any language and stands in until the lookup lands. - const glossLabel = - row.gloss || - resolvedOrEmpty(localizedStrings['%interlinearizer_analysisCatalog_noGloss%']) || - '—'; - - // One tooltip each rather than one for the row: either column may be the clipped one, and a - // tooltip is worth opening only over the text that is actually cut off. - const surfaceTooltip = useTruncationTooltip(); - const glossTooltip = useTruncationTooltip(); - /** * Scrolls the row into view each time `revealRequest` takes a new value, so a re-render that * leaves it unchanged never drags a reader back. @@ -215,97 +200,26 @@ function CatalogRowView({ return (
  • - {/* - Carries no `aria-label`: a name on a button overrides its content, so one here would - announce every row alike and suppress the analysis each lists. - */} - + {isExpanded && (
    void; + /** Resolved localizations covering at least {@link SELECTION_BAR_STRING_KEYS}. */ + localizedStrings: LanguageStrings; + /** Clears the gloss on every checked analysis. */ + onClearGloss: () => void; + /** + * Opens a merge of the checked analyses. Absent unless they can be merged, which takes two or + * more analyses of one form. + */ + onMerge?: () => void; + /** Deletes every checked analysis. */ + onDelete: () => void; +}>; + +/** + * Heads the catalog's list with a control checking every listed row, and once any is checked, the + * actions that apply to all of them at once. + */ +export default function CatalogSelectionBar({ + count, + usageCount, + allChecked, + onCheckAll, + localizedStrings, + onClearGloss, + onMerge, + onDelete, +}: CatalogSelectionBarProps) { + return ( +
    + {/* As tall as a small button, so the list does not shift when the actions appear. */} +
    + +
    + {count > 0 && ( + <> + + {formatReplacementString( + localizedStrings['%interlinearizer_analysisCatalog_checkedSummary%'], + { count, usageCount }, + )} + +
    + {onMerge && ( + + )} + + +
    + + )} +
    + ); +} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 2e8d5243..4dc49851 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -268,6 +268,10 @@ const STRING_KEYS = [ '%interlinearizer_redone_catalogDelete%', '%interlinearizer_undone_catalogDeleteUnused%', '%interlinearizer_redone_catalogDeleteUnused%', + '%interlinearizer_undone_catalogDeleteChecked%', + '%interlinearizer_redone_catalogDeleteChecked%', + '%interlinearizer_undone_catalogClearGlossChecked%', + '%interlinearizer_redone_catalogClearGlossChecked%', '%interlinearizer_undone_wipeBook%', '%interlinearizer_redone_wipeBook%', '%interlinearizer_undone_wipeAll%', diff --git a/src/hooks/useDraftDiscardGate.ts b/src/hooks/useDraftDiscardGate.ts new file mode 100644 index 00000000..a17e972e --- /dev/null +++ b/src/hooks/useDraftDiscardGate.ts @@ -0,0 +1,98 @@ +import { useCallback, useState } from 'react'; +import useLatestRef from './useLatestRef'; + +/** An action held back until the reader agrees to give up every unsaved draft it would drop. */ +type HeldAction = Readonly<{ + /** What the ask names the draft as given up for. */ + action: Action; + /** Reads the rows the held action would drop as things stand when it is called. */ + readAnalysisIds: () => readonly string[]; + /** Rows whose drafts the reader already agreed to discard. */ + confirmedIds: readonly string[]; + /** The held action, given the rows it drops as last read. */ + run: (analysisIds: readonly string[]) => void; +}>; + +/** + * The ask waiting on the reader to give up an unsaved draft, with the controls raising and + * answering it. + */ +export type DraftDiscardGate = Readonly<{ + /** The ask awaiting the reader, naming the row whose draft it asks about. */ + asking: Readonly<{ action: Action; analysisId: string }> | undefined; + /** + * Runs `run` on the rows `readAnalysisIds` names once the reader has agreed to give up every + * unsaved draft on them, asking about each in turn, or at once when none holds one. The rows are + * read afresh at each step, so an edit landing mid-ask cannot drop a draft never asked about. + */ + request: ( + action: Action, + readAnalysisIds: () => readonly string[], + run: (analysisIds: readonly string[]) => void, + ) => void; + /** + * Gives up the draft asked about and resumes the held action, the draft going only once the + * action runs and only if it still drops that row. + */ + confirm: () => void; + /** Abandons the held action, keeping every draft. */ + cancel: () => void; +}>; + +/** + * Holds back an action that would drop rows holding unsaved drafts until the reader agrees to give + * up each draft — a discard being a decision per draft rather than one taken for all of them. + */ +export default function useDraftDiscardGate( + hasUnsavedDraft: (analysisId: string) => boolean, + discardDraft: (analysisId: string) => void, +): DraftDiscardGate { + const [asking, setAsking] = useState<(HeldAction & { analysisId: string }) | undefined>( + undefined, + ); + + // Read at each step rather than at the request, so a draft saved or canceled meanwhile is not + // asked about. + const hasUnsavedDraftRef = useLatestRef(hasUnsavedDraft); + + const proceed = useCallback( + (held: HeldAction) => { + const analysisIds = held.readAnalysisIds(); + const analysisId = analysisIds.find( + (id) => !held.confirmedIds.includes(id) && hasUnsavedDraftRef.current(id), + ); + if (analysisId !== undefined) { + setAsking({ ...held, analysisId }); + return; + } + setAsking(undefined); + held.confirmedIds.filter((id) => analysisIds.includes(id)).forEach((id) => discardDraft(id)); + held.run(analysisIds); + }, + [discardDraft, hasUnsavedDraftRef], + ); + + const request = useCallback( + ( + action: Action, + readAnalysisIds: () => readonly string[], + run: (analysisIds: readonly string[]) => void, + ) => proceed({ action, readAnalysisIds, confirmedIds: [], run }), + [proceed], + ); + + const confirm = useCallback(() => { + /* v8 ignore next -- unreachable: the ask that calls this shows only while one is held */ + if (!asking) return; + proceed({ ...asking, confirmedIds: [...asking.confirmedIds, asking.analysisId] }); + }, [asking, proceed]); + + const cancel = useCallback(() => setAsking(undefined), []); + + return { + asking: asking && { action: asking.action, analysisId: asking.analysisId }, + request, + confirm, + cancel, + }; +} diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index fe4df373..ba647fc0 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -96,6 +96,18 @@ export type StepSummary = Readonly< /** How many uses the deleted analysis had, never zero. */ count: number; }) + | { + kind: 'catalogDeleteChecked'; + /** How many analyses were deleted. */ + count: number; + /** How many uses they had between them. */ + usageCount: number; + } + | { + kind: 'catalogClearGlossChecked'; + /** How many analyses had their gloss cleared. */ + count: number; + } | { kind: 'wipeBook'; book: string } | { kind: 'wipeAll' } >; diff --git a/src/hooks/useRowSelection.ts b/src/hooks/useRowSelection.ts new file mode 100644 index 00000000..e1778dcf --- /dev/null +++ b/src/hooks/useRowSelection.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +/** The rows a bulk action applies to, with the controls that choose them. */ +export type RowSelection = Readonly<{ + /** The checked rows the listing still holds, by analysis id. */ + checkedIds: ReadonlySet; + setChecked: (analysisId: string, checked: boolean) => void; + /** Checks every listed row, or unchecks them all. */ + setAllChecked: (checked: boolean) => void; +}>; + +/** + * Tracks which of the listed `rows` the reader has checked for a bulk action. A row the listing + * stops holding is unchecked for good, so an action never reaches a row the reader cannot see and + * widening the listing again does not bring it back checked. + */ +export default function useRowSelection( + rows: readonly Readonly<{ analysisId: string }>[], +): RowSelection { + /** The rows the reader checked, some of which the listing may have since dropped. */ + const [chosenCheckedIds, setChosenCheckedIds] = useState>(new Set()); + + const checkedIds = useMemo(() => { + const listed = rows.filter((r) => chosenCheckedIds.has(r.analysisId)); + return listed.length === chosenCheckedIds.size + ? chosenCheckedIds + : new Set(listed.map((r) => r.analysisId)); + }, [rows, chosenCheckedIds]); + + useEffect(() => { + if (checkedIds !== chosenCheckedIds) setChosenCheckedIds(checkedIds); + }, [checkedIds, chosenCheckedIds]); + + const setChecked = useCallback((analysisId: string, checked: boolean) => { + setChosenCheckedIds((ids) => { + const next = new Set(ids); + if (checked) next.add(analysisId); + else next.delete(analysisId); + return next; + }); + }, []); + + const setAllChecked = useCallback( + (checked: boolean) => + setChosenCheckedIds(new Set(checked ? rows.map((r) => r.analysisId) : [])), + [rows], + ); + + return useMemo( + () => ({ checkedIds, setChecked, setAllChecked }), + [checkedIds, setChecked, setAllChecked], + ); +} diff --git a/src/store/analysisSlice.ts b/src/store/analysisSlice.ts index d12837b7..1a059f91 100644 --- a/src/store/analysisSlice.ts +++ b/src/store/analysisSlice.ts @@ -810,6 +810,40 @@ function removeAnalysisAndLinks(state: AnalysisState, analysisId: string): void ); } +/** + * Writes `value` as the active language's gloss on the record `analysisId` names, for every token + * linked to it, removing a record the write empties and collapsing one it makes identical to a + * sibling. A blank `value` clears the gloss. + */ +function writeGlossOntoAnalysis( + state: AnalysisState, + analysisId: string, + value: string, + now: string, +): void { + const lang = state.analysisLanguage; + + const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); + if (!analysis) return; + state.lastCollapseSurvivorId = undefined; + + if (value.trim() === '') { + clearAnalysisGloss(analysis, lang); + } else { + if (!analysis.gloss) analysis.gloss = {}; + analysis.gloss[lang] = value; + delete analysis.glossSenseRef; + } + analysis.updatedAt = now; + + // Removed outright rather than left as an empty payload the pool would still carry. + if (isEmptyTokenAnalysis(analysis)) { + removeAnalysisAndLinks(state, analysisId); + return; + } + mergeIntoIdenticalPayload(state, analysis, now); +} + /** * Determines whether a `TokenAnalysis` carries no analysis content, so a reducer that just emptied * one field can decide to drop the whole record instead of letting empty records accumulate in @@ -1190,27 +1224,21 @@ const analysisSlice = createSlice({ }, reducer(state, action: PayloadAction<{ analysisId: string; value: string; now: string }>) { const { analysisId, value, now } = action.payload; - const lang = state.analysisLanguage; - - const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); - if (!analysis) return; - state.lastCollapseSurvivorId = undefined; - - if (value.trim() === '') { - clearAnalysisGloss(analysis, lang); - } else { - if (!analysis.gloss) analysis.gloss = {}; - analysis.gloss[lang] = value; - delete analysis.glossSenseRef; - } - analysis.updatedAt = now; - - // Removed outright rather than left as an empty payload the pool would still carry. - if (isEmptyTokenAnalysis(analysis)) { - removeAnalysisAndLinks(state, analysisId); - return; - } - mergeIntoIdenticalPayload(state, analysis, now); + writeGlossOntoAnalysis(state, analysisId, value, now); + }, + }, + /** + * Clears the active language's gloss on several `TokenAnalysis` records at once, as + * {@link writeAnalysisGloss} clears one. + */ + clearAnalysisGlosses: { + /** Reads the clock before the action reaches the reducer, keeping the reducer pure. */ + prepare(arg: { analysisIds: readonly string[] }) { + return { payload: { ...arg, now: nowIso() } }; + }, + reducer(state, action: PayloadAction<{ analysisIds: readonly string[]; now: string }>) { + const { analysisIds, now } = action.payload; + analysisIds.forEach((analysisId) => writeGlossOntoAnalysis(state, analysisId, '', now)); }, }, /** @@ -1325,14 +1353,15 @@ const analysisSlice = createSlice({ }, }, /** - * Removes a `TokenAnalysis` and every link to it. Its tokens fall back to whatever the - * suggestion pool still offers for their surface form — a surviving homograph, or nothing, in - * which case they read as blank; {@link selectAnalysisDeletionOutcome} reports which. + * Removes `TokenAnalysis` records and every link to them. Their tokens fall back to whatever + * the suggestion pool still offers for their surface form — a surviving homograph, or nothing, + * in which case they read as blank; {@link selectAnalysisDeletionOutcome} reports this per + * record. * * The only reducer that drops a record the user never emptied. */ - deleteAnalysis(state, action: PayloadAction<{ analysisId: string }>) { - removeAnalysisAndLinks(state, action.payload.analysisId); + deleteAnalyses(state, action: PayloadAction<{ analysisIds: readonly string[] }>) { + action.payload.analysisIds.forEach((analysisId) => removeAnalysisAndLinks(state, analysisId)); }, /** * Folds several `TokenAnalysis` records into one and writes the content they agreed on onto it, @@ -1863,7 +1892,8 @@ export const { writeAnalysisMorphemes, confirmAnalysisMorphemes, writeAnalysisMorphemeGloss, - deleteAnalysis, + deleteAnalyses, + clearAnalysisGlosses, mergeAnalysesInto, approveAnalysisForToken, discardStaleAnalysis, @@ -2080,7 +2110,26 @@ export interface AnalysisDeletionOutcome { } /** - * Reports what {@link deleteAnalysis} would do to the given row, for its announcement to name. + * The records among `analysisIds` that {@link clearAnalysisGlosses} would leave with nothing, and so + * delete, in store order. + */ +export function selectEmptiedByGlossClear( + state: AnalysisState, + analysisIds: readonly string[], +): string[] { + const listed = new Set(analysisIds); + return state.analysis.tokenAnalyses + .filter((ta) => { + if (!listed.has(ta.id)) return false; + const cleared = { ...ta, gloss: ta.gloss && { ...ta.gloss } }; + clearAnalysisGloss(cleared, state.analysisLanguage); + return isEmptyTokenAnalysis(cleared); + }) + .map((ta) => ta.id); +} + +/** + * Reports what {@link deleteAnalyses} would do to the given row, for its announcement to name. * Returns `undefined` when the id resolves to no payload, so a stale row cannot delete a record * that is already gone. *