Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const BOOK_CHAPTER_CONTROL_STRING_KEYS = [
export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_select_project%',
command: 'interlinearizer.openSelectProjectModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 1,
localizeNotes: '',
};
Expand All @@ -51,7 +51,7 @@ export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_new_project%',
command: 'interlinearizer.openNewProjectModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 2,
localizeNotes: '',
};
Expand All @@ -60,11 +60,47 @@ export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
export const MOCK_VIEW_PROJECT_INFO_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_view_project_info%',
command: 'interlinearizer.openProjectInfoModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 3,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the save button is clicked. */
export const MOCK_SAVE_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_save%',
command: 'interlinearizer.save',
group: 'interlinearizer.fileActions',
order: 1,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the save-as button is clicked. */
export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_saveAs%',
command: 'interlinearizer.openSaveAsModal',
group: 'interlinearizer.fileActions',
order: 2,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the wipe-book button is clicked. */
export const MOCK_WIPE_BOOK_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_wipeBook%',
command: 'interlinearizer.wipeBook',
group: 'interlinearizer.draftActions',
order: 1,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the wipe-draft button is clicked. */
export const MOCK_WIPE_DRAFT_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_wipeDraft%',
command: 'interlinearizer.wipeDraft',
group: 'interlinearizer.draftActions',
order: 2,
localizeNotes: '',
};


/**
* Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests
Expand Down Expand Up @@ -127,6 +163,42 @@ export function TabToolbar({
View project info
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-save"
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_MENU_ITEM)}
>
Save
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-save-as"
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_AS_MENU_ITEM)}
>
Save as
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-wipe-book"
onClick={() => onSelectProjectMenuItem(MOCK_WIPE_BOOK_MENU_ITEM)}
>
Wipe book
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-wipe-draft"
onClick={() => onSelectProjectMenuItem(MOCK_WIPE_DRAFT_MENU_ITEM)}
>
Wipe draft
</button>
)}
{onSelectViewInfoMenuItem && (
<button
type="button"
Expand Down
31 changes: 30 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"%interlinearizer_openSelectProjectModal%": "Select Interlinear Project…",
"%interlinearizer_openNewProjectModal%": "New Interlinear Project…",
"%interlinearizer_openProjectInfoModal%": "View Project Info…",
"%interlinearizer_save%": "Save",
"%interlinearizer_saveAs%": "Save As…",
"%interlinearizer_wipeBook%": "Wipe Current Book…",
"%interlinearizer_wipeDraft%": "Wipe Draft…",

"%interlinearizer_projectSettings_title%": "Interlinearizer",
"%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll",
Expand Down Expand Up @@ -68,14 +72,39 @@
"%interlinearizer_modal_select_none%": "No interlinear projects exist for this source yet.",
"%interlinearizer_modal_select_name_unnamed%": "Unnamed",
"%interlinearizer_modal_select_info_button_label%": "Project info",
"%interlinearizer_modal_select_active_badge%": "Active",
"%interlinearizer_modal_select_create_new%": "Create New",
"%interlinearizer_modal_select_cancel%": "Cancel",

"%interlinearizer_confirm_discard_title%": "Discard unsaved changes?",
"%interlinearizer_confirm_discard_body%": "Your draft has changes that haven't been saved to a project. Continuing will discard them.",
"%interlinearizer_confirm_discard_ok%": "Discard",
"%interlinearizer_confirm_discard_cancel%": "Cancel",

"%interlinearizer_modal_saveAs_title%": "Save As",
"%interlinearizer_modal_saveAs_new_section%": "Save as a new project",
"%interlinearizer_modal_saveAs_save_new%": "Save as New Project",
"%interlinearizer_modal_saveAs_existing_section%": "Or overwrite an existing project",
"%interlinearizer_modal_saveAs_none%": "No existing projects for this source yet.",
"%interlinearizer_modal_saveAs_overwrite%": "Overwrite",
"%interlinearizer_modal_saveAs_overwrite_confirm_body%": "Overwrite this project? Its saved analysis will be replaced with the current draft.",
"%interlinearizer_modal_saveAs_overwrite_confirm_ok%": "Overwrite",
"%interlinearizer_modal_saveAs_overwrite_confirm_cancel%": "Cancel",
"%interlinearizer_modal_saveAs_cancel%": "Cancel",

"%interlinearizer_confirm_wipe_book_title%": "Wipe current book?",
"%interlinearizer_confirm_wipe_book_body%": "This removes all analysis for the current book from the draft. Save afterward to persist the change to a project.",
"%interlinearizer_confirm_wipe_draft_title%": "Wipe entire draft?",
"%interlinearizer_confirm_wipe_draft_body%": "This removes all analysis from the draft. Save afterward to persist the change to a project.",
"%interlinearizer_confirm_wipe_ok%": "Wipe",
"%interlinearizer_confirm_wipe_cancel%": "Cancel",

"%interlinearizer_error_create_project_failed%": "Could not create the interlinearizer project. Please try again.",
"%interlinearizer_error_save_metadata_failed%": "Could not save project info. Please try again.",
"%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.",
"%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.",
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again."
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.",
"%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again."
}
}
}
36 changes: 36 additions & 0 deletions contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"interlinearizer.projectActions": {
"column": "interlinearizer.project",
"order": 1
},
"interlinearizer.fileActions": {
"column": "interlinearizer.project",
"order": 2
},
"interlinearizer.draftActions": {
"column": "interlinearizer.project",
"order": 3
}
},
"items": [
Expand All @@ -50,6 +58,34 @@
"group": "interlinearizer.projectActions",
"order": 3,
"command": "interlinearizer.openProjectInfoModal"
},
{
"label": "%interlinearizer_save%",
"localizeNotes": "Interlinearizer top menu > Save the current draft to the active project",
"group": "interlinearizer.fileActions",
"order": 1,
"command": "interlinearizer.save"
},
{
"label": "%interlinearizer_saveAs%",
"localizeNotes": "Interlinearizer top menu > Save the draft to a new project or overwrite an existing one",
"group": "interlinearizer.fileActions",
"order": 2,
"command": "interlinearizer.openSaveAsModal"
},
{
"label": "%interlinearizer_wipeBook%",
"localizeNotes": "Interlinearizer top menu > Remove the current book's analysis from the draft",
"group": "interlinearizer.draftActions",
"order": 1,
"command": "interlinearizer.wipeBook"
},
{
"label": "%interlinearizer_wipeDraft%",
"localizeNotes": "Interlinearizer top menu > Remove all analysis from the draft",
"group": "interlinearizer.draftActions",
"order": 2,
"command": "interlinearizer.wipeDraft"
}
]
}
Expand Down
109 changes: 109 additions & 0 deletions src/__tests__/components/AnalysisStore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
usePhraseDispatch,
usePhraseGloss,
usePhraseGlossDispatch,
useReportGlossEditing,
} from '../../components/AnalysisStore';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1146,3 +1147,111 @@ describe('useMorphemeGlossDispatch', () => {
);
});
});

/**
* Reports its `isEditing` prop through {@link useReportGlossEditing}, used to drive the provider's
* pending-edits accounting from tests.
*
* @param props - Component props.
* @param props.isEditing - Whether this stand-in input currently holds uncommitted text.
* @returns An empty fragment; the component exists only for its hook side effect.
*/
function EditingReporter({ isEditing }: Readonly<{ isEditing: boolean }>) {
useReportGlossEditing(isEditing);
return undefined;
}

describe('useReportGlossEditing', () => {
it('reports true when the first input starts editing and false when it stops', () => {
const onPendingEditsChange = jest.fn();
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
// No editor active yet: nothing reported.
expect(onPendingEditsChange).not.toHaveBeenCalled();

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('reports only the 0↔non-0 transitions when multiple inputs edit concurrently', () => {
const onPendingEditsChange = jest.fn();
const renderWith = (a: boolean, b: boolean) => (
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={a} />
<EditingReporter isEditing={b} />
</AnalysisStoreProvider>
);
const { rerender } = render(renderWith(false, false));
expect(onPendingEditsChange).not.toHaveBeenCalled();

rerender(renderWith(true, false));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

// Second input also starts editing: still pending, no new transition reported.
rerender(renderWith(true, true));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);

// First input stops: one editor remains, so still no transition.
rerender(renderWith(false, true));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);

// Last editor stops: now we cross back to zero.
rerender(renderWith(false, false));
expect(onPendingEditsChange).toHaveBeenCalledTimes(2);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('reports false when an actively-editing input unmounts', () => {
const onPendingEditsChange = jest.fn();
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<span />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('does not throw when no onPendingEditsChange is provided', () => {
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und">
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
expect(() =>
rerender(
<AnalysisStoreProvider analysisLanguage="und">
<EditingReporter isEditing />
</AnalysisStoreProvider>,
),
).not.toThrow();
});

it('throws when called outside an AnalysisStoreProvider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<EditingReporter isEditing={false} />)).toThrow(
'useReportGlossEditing must be used inside an AnalysisStoreProvider',
);
});
});
Loading
Loading