Skip to content

Commit ed812de

Browse files
Indicate active project better, improve pending edits system
1 parent 2775bb9 commit ed812de

17 files changed

Lines changed: 337 additions & 40 deletions

contributions/localizedStrings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"%interlinearizer_modal_select_none%": "No interlinear projects exist for this source yet.",
7373
"%interlinearizer_modal_select_name_unnamed%": "Unnamed",
7474
"%interlinearizer_modal_select_info_button_label%": "Project info",
75+
"%interlinearizer_modal_select_active_badge%": "Active",
7576
"%interlinearizer_modal_select_create_new%": "Create New",
7677
"%interlinearizer_modal_select_cancel%": "Cancel",
7778

src/__tests__/components/AnalysisStore.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
usePhraseDispatch,
2222
usePhraseGloss,
2323
usePhraseGlossDispatch,
24+
useReportGlossEditing,
2425
} from '../../components/AnalysisStore';
2526

2627
// ---------------------------------------------------------------------------
@@ -1146,3 +1147,111 @@ describe('useMorphemeGlossDispatch', () => {
11461147
);
11471148
});
11481149
});
1150+
1151+
/**
1152+
* Reports its `isEditing` prop through {@link useReportGlossEditing}, used to drive the provider's
1153+
* pending-edits accounting from tests.
1154+
*
1155+
* @param props - Component props.
1156+
* @param props.isEditing - Whether this stand-in input currently holds uncommitted text.
1157+
* @returns An empty fragment; the component exists only for its hook side effect.
1158+
*/
1159+
function EditingReporter({ isEditing }: Readonly<{ isEditing: boolean }>) {
1160+
useReportGlossEditing(isEditing);
1161+
return undefined;
1162+
}
1163+
1164+
describe('useReportGlossEditing', () => {
1165+
it('reports true when the first input starts editing and false when it stops', () => {
1166+
const onPendingEditsChange = jest.fn();
1167+
const { rerender } = render(
1168+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1169+
<EditingReporter isEditing={false} />
1170+
</AnalysisStoreProvider>,
1171+
);
1172+
// No editor active yet: nothing reported.
1173+
expect(onPendingEditsChange).not.toHaveBeenCalled();
1174+
1175+
rerender(
1176+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1177+
<EditingReporter isEditing />
1178+
</AnalysisStoreProvider>,
1179+
);
1180+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1181+
1182+
rerender(
1183+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1184+
<EditingReporter isEditing={false} />
1185+
</AnalysisStoreProvider>,
1186+
);
1187+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1188+
});
1189+
1190+
it('reports only the 0↔non-0 transitions when multiple inputs edit concurrently', () => {
1191+
const onPendingEditsChange = jest.fn();
1192+
const renderWith = (a: boolean, b: boolean) => (
1193+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1194+
<EditingReporter isEditing={a} />
1195+
<EditingReporter isEditing={b} />
1196+
</AnalysisStoreProvider>
1197+
);
1198+
const { rerender } = render(renderWith(false, false));
1199+
expect(onPendingEditsChange).not.toHaveBeenCalled();
1200+
1201+
rerender(renderWith(true, false));
1202+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1203+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1204+
1205+
// Second input also starts editing: still pending, no new transition reported.
1206+
rerender(renderWith(true, true));
1207+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1208+
1209+
// First input stops: one editor remains, so still no transition.
1210+
rerender(renderWith(false, true));
1211+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1212+
1213+
// Last editor stops: now we cross back to zero.
1214+
rerender(renderWith(false, false));
1215+
expect(onPendingEditsChange).toHaveBeenCalledTimes(2);
1216+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1217+
});
1218+
1219+
it('reports false when an actively-editing input unmounts', () => {
1220+
const onPendingEditsChange = jest.fn();
1221+
const { rerender } = render(
1222+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1223+
<EditingReporter isEditing />
1224+
</AnalysisStoreProvider>,
1225+
);
1226+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1227+
1228+
rerender(
1229+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1230+
<span />
1231+
</AnalysisStoreProvider>,
1232+
);
1233+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1234+
});
1235+
1236+
it('does not throw when no onPendingEditsChange is provided', () => {
1237+
const { rerender } = render(
1238+
<AnalysisStoreProvider analysisLanguage="und">
1239+
<EditingReporter isEditing={false} />
1240+
</AnalysisStoreProvider>,
1241+
);
1242+
expect(() =>
1243+
rerender(
1244+
<AnalysisStoreProvider analysisLanguage="und">
1245+
<EditingReporter isEditing />
1246+
</AnalysisStoreProvider>,
1247+
),
1248+
).not.toThrow();
1249+
});
1250+
1251+
it('throws when called outside an AnalysisStoreProvider', () => {
1252+
jest.spyOn(console, 'error').mockImplementation(() => {});
1253+
expect(() => render(<EditingReporter isEditing={false} />)).toThrow(
1254+
'useReportGlossEditing must be used inside an AnalysisStoreProvider',
1255+
);
1256+
});
1257+
});

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ type CapturedInterlinearizerProps = {
135135
analysisLanguage: string;
136136
initialAnalysis?: TextAnalysis;
137137
onSaveAnalysis?: (analysis: TextAnalysis) => void;
138+
onPendingEditsChange?: (pending: boolean) => void;
138139
phraseMode: PhraseMode;
139140
setPhraseMode: Dispatch<SetStateAction<PhraseMode>>;
140141
viewOptions: ViewOptions;
@@ -1013,6 +1014,28 @@ describe('InterlinearizerLoader', () => {
10131014
expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer' });
10141015
});
10151016

1017+
it('shows the tab unsaved marker for in-progress typing before the gloss commits', async () => {
1018+
let result: ReturnType<typeof renderLoader> | undefined;
1019+
await act(async () => {
1020+
result = renderLoader();
1021+
});
1022+
const updateWebViewDefinition = result?.updateWebViewDefinition;
1023+
1024+
// A gloss input begins holding uncommitted text: the marker appears even though no gloss has
1025+
// been written (the persisted draft is still clean).
1026+
act(() => {
1027+
capturedInterlinearizerProps?.onPendingEditsChange?.(true);
1028+
});
1029+
expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer ●' });
1030+
1031+
// The edit is reverted or the input unmounts with nothing committed: the marker clears.
1032+
updateWebViewDefinition?.mockClear();
1033+
act(() => {
1034+
capturedInterlinearizerProps?.onPendingEditsChange?.(false);
1035+
});
1036+
expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer' });
1037+
});
1038+
10161039
it('logs an error when the saveAnalysis command rejects during Save', async () => {
10171040
await act(async () =>
10181041
renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }),
@@ -1094,7 +1117,8 @@ describe('InterlinearizerLoader', () => {
10941117
return typeof json === 'string' ? JSON.parse(json) : undefined;
10951118
})();
10961119
expect(wiped?.analysis).toEqual(emptyAnalysis());
1097-
expect(wiped?.dirty).toBe(true);
1120+
// Wiping the whole draft is treated as a clean baseline, so it persists not-dirty.
1121+
expect(wiped?.dirty).toBe(false);
10981122
expect(screen.queryByTestId('wipe-confirm')).not.toBeInTheDocument();
10991123
});
11001124

src/__tests__/components/PhraseBox.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ jest.mock('../../components/AnalysisStore', () => ({
4545
usePhraseDispatch: () => mockUsePhraseDispatch(),
4646
usePhraseGloss: (...args: Parameters<typeof mockUsePhraseGloss>) => mockUsePhraseGloss(...args),
4747
usePhraseGlossDispatch: () => mockUsePhraseGlossDispatch(),
48+
useReportGlossEditing: () => {},
4849
}));
4950

5051
jest.mock('../../components/TokenChip', () => {

src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const LOCALIZED: Record<string, string> = {
1818
'%interlinearizer_modal_select_cancel%': 'Cancel',
1919
'%interlinearizer_modal_select_name_unnamed%': 'Unnamed',
2020
'%interlinearizer_modal_select_info_button_label%': 'Project info',
21+
'%interlinearizer_modal_select_active_badge%': 'Active',
2122
};
2223

2324
const STUB_PROJECT: InterlinearProjectSummary = {
@@ -72,6 +73,28 @@ describe('SelectInterlinearProjectModal', () => {
7273
expect(screen.getByText('Unnamed')).toBeInTheDocument();
7374
});
7475

76+
it('badges the active project and marks its row aria-current', async () => {
77+
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, STUB_PROJECT_2]));
78+
render(<SelectInterlinearProjectModal {...defaultProps} activeProjectId={STUB_PROJECT_2.id} />);
79+
await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());
80+
81+
expect(screen.getByText('Active')).toBeInTheDocument();
82+
// The active badge appears on the active project's row, not the other one.
83+
expect(screen.getByRole('button', { name: /french glosses/i })).toHaveAttribute(
84+
'aria-current',
85+
'true',
86+
);
87+
expect(screen.getByRole('button', { name: /unnamed/i })).not.toHaveAttribute('aria-current');
88+
});
89+
90+
it('shows no active badge when no project matches activeProjectId', async () => {
91+
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, STUB_PROJECT_2]));
92+
render(<SelectInterlinearProjectModal {...defaultProps} activeProjectId="not-in-list" />);
93+
await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());
94+
95+
expect(screen.queryByText('Active')).not.toBeInTheDocument();
96+
});
97+
7598
it('shows the analysis writing system code in each row', async () => {
7699
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT]));
77100
render(<SelectInterlinearProjectModal {...defaultProps} />);

src/__tests__/hooks/useDraftProject.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,19 +300,21 @@ describe('useDraftProject', () => {
300300
});
301301

302302
describe('wipeAll', () => {
303-
it('clears the analysis entirely, marks the draft dirty, bumps the version, and persists', async () => {
304-
mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-wipe-all') }));
303+
it('clears the analysis entirely, clears dirty (clean baseline), bumps the version, and persists', async () => {
304+
// Start from a dirty draft so the transition to clean is observable.
305+
mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-wipe-all'), dirty: true }));
305306
const { result } = await renderLoaded();
307+
expect(result.current.dirty).toBe(true);
306308
const versionBefore = result.current.draftVersion;
307309

308310
act(() => {
309311
result.current.wipeAll();
310312
});
311313

312314
expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]);
313-
expect(result.current.dirty).toBe(true);
315+
expect(result.current.dirty).toBe(false);
314316
expect(result.current.draftVersion).toBe(versionBefore + 1);
315-
expect(lastSavedDraft().dirty).toBe(true);
317+
expect(lastSavedDraft().dirty).toBe(false);
316318
});
317319
});
318320

src/components/AnalysisStore.tsx

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
TextAnalysis,
66
TokenSnapshot,
77
} from 'interlinearizer';
8-
import { createContext, useCallback, useContext, useMemo, useRef } from 'react';
8+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
99
import type { ReactNode } from 'react';
1010
import { Provider as ReduxProvider, useDispatch, useSelector, useStore } from 'react-redux';
1111
import {
@@ -41,6 +41,14 @@ type CallbackRefs = {
4141
onSaveRef: { current: ((analysis: TextAnalysis) => void) | undefined };
4242
/** Ref to the `onGlossChange` spy prop of the nearest {@link AnalysisStoreProvider}. */
4343
onGlossChangeRef: { current: ((tokenRef: string, value: string) => void) | undefined };
44+
/**
45+
* Stable callback a gloss input calls to register (`true`) or unregister (`false`) itself as
46+
* currently holding uncommitted text. Gloss edits are committed to the store only on blur, so
47+
* without this the provider could not tell the parent that an edit is in progress until then. The
48+
* provider counts active editors and reports the 0↔non-0 crossings via the `onPendingEditsChange`
49+
* prop, driving the tab's unsaved indicator while the user types.
50+
*/
51+
reportEditing: (active: boolean) => void;
4452
};
4553

4654
/** Internal context that carries callback refs alongside the Redux {@link ReduxProvider}. */
@@ -70,6 +78,12 @@ type AnalysisStoreProviderProps = Readonly<{
7078
* effect on store behavior.
7179
*/
7280
onGlossChange?: (tokenRef: string, value: string) => void;
81+
/**
82+
* Called with `true` when at least one gloss input begins holding uncommitted text and `false`
83+
* when none do (the last edit is committed on blur, reverted, or the input unmounts). Lets the
84+
* caller reflect in-progress typing in the tab's unsaved indicator before the edit commits.
85+
*/
86+
onPendingEditsChange?: (pending: boolean) => void;
7387
}>;
7488

7589
/**
@@ -83,6 +97,8 @@ type AnalysisStoreProviderProps = Readonly<{
8397
* @param props.analysisLanguage - BCP 47 tag for reading/writing gloss values
8498
* @param props.onSave - Callback receiving the updated `TextAnalysis` after each mutation
8599
* @param props.onGlossChange - Spy called after each gloss write; for test observability only
100+
* @param props.onPendingEditsChange - Called with whether any gloss input currently holds
101+
* uncommitted text, so the caller can show the unsaved indicator while the user types
86102
* @returns A context provider wrapping the subtree
87103
*/
88104
export function AnalysisStoreProvider({
@@ -91,6 +107,7 @@ export function AnalysisStoreProvider({
91107
analysisLanguage,
92108
onSave,
93109
onGlossChange,
110+
onPendingEditsChange,
94111
}: AnalysisStoreProviderProps) {
95112
// Lazy initialization: useRef(createStore()) would create and discard a store on every render
96113
const storeRef = useRef<ReturnType<typeof createAnalysisStore> | undefined>(undefined);
@@ -106,8 +123,24 @@ export function AnalysisStoreProvider({
106123
onSaveRef.current = onSave;
107124
const onGlossChangeRef = useRef(onGlossChange);
108125
onGlossChangeRef.current = onGlossChange;
109-
110-
const callbackRefs = useMemo(() => ({ onSaveRef, onGlossChangeRef }), []);
126+
const onPendingEditsChangeRef = useRef(onPendingEditsChange);
127+
onPendingEditsChangeRef.current = onPendingEditsChange;
128+
129+
// Count of gloss inputs currently holding uncommitted text. Kept in a ref (not state) so
130+
// registering/unregistering an editor never re-renders the provider; only the 0↔non-0 crossings
131+
// are forwarded to the parent, which owns the indicator.
132+
const editingCountRef = useRef(0);
133+
const reportEditing = useCallback((active: boolean) => {
134+
const wasPending = editingCountRef.current > 0;
135+
editingCountRef.current += active ? 1 : -1;
136+
const isPending = editingCountRef.current > 0;
137+
if (wasPending !== isPending) onPendingEditsChangeRef.current?.(isPending);
138+
}, []);
139+
140+
const callbackRefs = useMemo(
141+
() => ({ onSaveRef, onGlossChangeRef, reportEditing }),
142+
[reportEditing],
143+
);
111144

112145
return (
113146
<ReduxProvider store={store}>
@@ -158,6 +191,25 @@ function useAnalysisSave(hookName: string): {
158191
return { callbacks, dispatch, save };
159192
}
160193

194+
/**
195+
* Registers a gloss input as currently holding uncommitted text whenever `isEditing` is true,
196+
* unregistering it when `isEditing` turns false or the input unmounts. The provider aggregates
197+
* these across all inputs and reports whether any edit is in progress, so the tab's unsaved
198+
* indicator can light up the moment the user starts typing — gloss writes themselves are deferred
199+
* to blur, which would otherwise leave the indicator dark mid-edit.
200+
*
201+
* @param isEditing - Whether this input's draft currently differs from its committed value.
202+
* @throws When called outside an {@link AnalysisStoreProvider}.
203+
*/
204+
export function useReportGlossEditing(isEditing: boolean): void {
205+
const { reportEditing } = useRequiredCallbacks('useReportGlossEditing');
206+
useEffect(() => {
207+
if (!isEditing) return undefined;
208+
reportEditing(true);
209+
return () => reportEditing(false);
210+
}, [isEditing, reportEditing]);
211+
}
212+
161213
// #endregion
162214

163215
// #region Token hooks

src/components/Interlinearizer.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ type InterlinearizerProps = Readonly<{
5050
initialAnalysis?: TextAnalysis;
5151
/** Called after each gloss write with the updated `TextAnalysis` so the caller can persist it. */
5252
onSaveAnalysis?: (analysis: TextAnalysis) => void;
53+
/**
54+
* Called with whether any gloss input currently holds uncommitted text, so the caller can show
55+
* the unsaved indicator while the user is typing — before the edit commits on blur.
56+
*/
57+
onPendingEditsChange?: (pending: boolean) => void;
5358
/** Current phrase-interaction mode; owned by the parent and passed down for rendering. */
5459
phraseMode: PhraseMode;
5560
/** Setter for `phraseMode`; passed down so child components can transition modes. */
@@ -312,6 +317,8 @@ function InterlinearizerInner({
312317
* @param props.initialAnalysis - Seed analysis data for the store; not reactive after mount
313318
* @param props.analysisLanguage - BCP 47 tag for gloss read/write
314319
* @param props.onSaveAnalysis - Called after each gloss write with the updated `TextAnalysis`
320+
* @param props.onPendingEditsChange - Called with whether any gloss input currently holds
321+
* uncommitted text, so the caller can show the unsaved indicator while the user types
315322
* @param props.phraseMode - Current phrase-interaction mode owned by the parent
316323
* @param props.setPhraseMode - Setter for `phraseMode`
317324
* @param props.viewOptions - Bundled display toggles forwarded to the segment list and continuous
@@ -322,13 +329,15 @@ export default function Interlinearizer({
322329
initialAnalysis,
323330
analysisLanguage,
324331
onSaveAnalysis,
332+
onPendingEditsChange,
325333
...innerProps
326334
}: InterlinearizerProps) {
327335
return (
328336
<AnalysisStoreProvider
329337
initialAnalysis={initialAnalysis}
330338
analysisLanguage={analysisLanguage}
331339
onSave={onSaveAnalysis}
340+
onPendingEditsChange={onPendingEditsChange}
332341
>
333342
<InterlinearizerInner {...innerProps} />
334343
</AnalysisStoreProvider>

0 commit comments

Comments
 (0)