+
@@ -43,9 +49,9 @@ jest.mock('../../components/MorphemeEditor', () => ({
- {onDelete && (
-
@@ -513,7 +519,7 @@ describe('TokenChip', () => {
expect(mockDispatch).not.toHaveBeenCalled();
});
- it('dispatches morpheme deletion when the popover delete is clicked', async () => {
+ it('dispatches morpheme deletion when the popover reset is clicked', async () => {
const mockDispatch = jest.fn();
// AnalysisStore imported at top level
jest
@@ -527,11 +533,11 @@ describe('TokenChip', () => {
,
);
await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' }));
- await userEvent.click(screen.getByRole('button', { name: 'mock-delete' }));
+ await userEvent.click(screen.getByRole('button', { name: 'mock-reset' }));
expect(mockDispatch).toHaveBeenCalledWith('GEN 1:1:0');
});
- it('passes no onDelete to the popover when the token has no breakdown', async () => {
+ it('passes no onReset to the popover when the token has no breakdown', async () => {
render(
@@ -541,7 +547,43 @@ describe('TokenChip', () => {
screen.getByRole('button', { name: 'Define morpheme breakdown for hello' }),
);
expect(screen.getByTestId('morpheme-popover')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'mock-delete' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'mock-reset' })).not.toBeInTheDocument();
+ });
+
+ it('tells the popover to confirm when a reset would lose glosses', async () => {
+ // AnalysisStore imported at top level
+ jest.spyOn(AnalysisStore, 'useMorphemeResetLosesGlosses').mockReturnValue(true);
+
+ render(
+
+
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Define morpheme breakdown for hello' }),
+ );
+ expect(screen.getByTestId('morpheme-popover')).toHaveAttribute(
+ 'data-needs-reset-confirm',
+ 'true',
+ );
+ });
+
+ it('tells the popover not to confirm when a reset would lose nothing', async () => {
+ // AnalysisStore imported at top level
+ jest.spyOn(AnalysisStore, 'useMorphemeResetLosesGlosses').mockReturnValue(false);
+
+ render(
+
+
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Define morpheme breakdown for hello' }),
+ );
+ expect(screen.getByTestId('morpheme-popover')).toHaveAttribute(
+ 'data-needs-reset-confirm',
+ 'false',
+ );
});
it('focuses the main gloss input on a surface-text mouse-down when the box precedes it', () => {
diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts
index 3d37b27d..98d3d845 100644
--- a/src/__tests__/store/analysisSlice.test.ts
+++ b/src/__tests__/store/analysisSlice.test.ts
@@ -19,6 +19,7 @@ import {
mergePhrases,
selectApprovedGloss,
selectApprovedMorphemes,
+ selectMorphemeResetLosesGlosses,
selectPhraseLinkByTokenRef,
selectPhraseGloss,
selectPhraseLinks,
@@ -2089,3 +2090,51 @@ describe('approveAnalysisForToken', () => {
expect(store.getState().analysis.analysis.tokenAnalyses).toHaveLength(1);
});
});
+
+describe('selectMorphemeResetLosesGlosses', () => {
+ /**
+ * Writes a two-morpheme breakdown for `tokenRef` and returns the id of its first morpheme, so
+ * tests can gloss a morpheme whose id the reducer generated.
+ *
+ * @param store - The analysis store to dispatch into.
+ * @param tokenRef - The `Token.ref` to break down.
+ * @returns The first morpheme's generated id.
+ */
+ function breakDown(store: ReturnType, tokenRef: string): string {
+ store.dispatch(writeMorphemes(tokenRef, 'cats', ['cat', '-s'], 'en'));
+ return selectApprovedMorphemes(store.getState().analysis, tokenRef)[0].id;
+ }
+
+ it('reports no loss when the token has no approved analysis', () => {
+ const store = createAnalysisStore();
+ expect(selectMorphemeResetLosesGlosses(store.getState().analysis, 'tok-1')).toBe(false);
+ });
+
+ it('reports no loss when the breakdown carries no morpheme glosses', () => {
+ // Bare segmentation is cheap to retype, so removing it needs no confirmation.
+ const store = createAnalysisStore();
+ breakDown(store, 'tok-1');
+ expect(selectMorphemeResetLosesGlosses(store.getState().analysis, 'tok-1')).toBe(false);
+ });
+
+ it('reports a loss when a glossed breakdown is linked only by this token', () => {
+ const store = createAnalysisStore();
+ const morphemeId = breakDown(store, 'tok-1');
+ store.dispatch(writeMorphemeGloss({ tokenRef: 'tok-1', morphemeId, value: 'feline' }));
+ expect(selectMorphemeResetLosesGlosses(store.getState().analysis, 'tok-1')).toBe(true);
+ });
+
+ it('reports no loss when a glossed breakdown is shared with another token', () => {
+ // deleteMorphemes forks a shared payload rather than emptying it, so the co-linked token keeps
+ // the glosses and nothing is lost project-wide.
+ const store = createAnalysisStore();
+ const morphemeId = breakDown(store, 'tok-1');
+ store.dispatch(writeMorphemeGloss({ tokenRef: 'tok-1', morphemeId, value: 'feline' }));
+ const state = store.getState().analysis;
+ const [{ analysisId }] = state.analysis.tokenAnalysisLinks.filter(
+ (l) => l.token.tokenRef === 'tok-1',
+ );
+ store.dispatch(approveAnalysisForToken({ tokenRef: 'tok-2', surfaceText: 'cats', analysisId }));
+ expect(selectMorphemeResetLosesGlosses(store.getState().analysis, 'tok-2')).toBe(false);
+ });
+});
diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx
index 1b5fffba..b4eb01de 100644
--- a/src/components/AnalysisStore.tsx
+++ b/src/components/AnalysisStore.tsx
@@ -19,6 +19,7 @@ import {
selectAnalysisLanguage,
selectApprovedGloss,
selectApprovedMorphemes,
+ selectMorphemeResetLosesGlosses,
selectPhraseLinkByAnalysisId,
selectPhraseLinkByTokenRef,
selectPhraseGloss,
@@ -371,6 +372,23 @@ export function useMorphemes(tokenRef: string): readonly MorphemeAnalysis[] {
);
}
+/**
+ * Returns whether resetting `tokenRef`'s morpheme breakdown would discard glosses no other token
+ * still holds, so the morpheme editor can confirm before an irreversible loss (the app has no
+ * undo). See {@link selectMorphemeResetLosesGlosses} for why a shared payload does not qualify.
+ *
+ * @param tokenRef - The `Token.ref` whose breakdown would be reset.
+ * @returns `true` when the reset needs confirmation.
+ * @throws When called outside an {@link AnalysisStoreProvider}.
+ */
+export function useMorphemeResetLosesGlosses(tokenRef: string): boolean {
+ useRequiredCallbacks('useMorphemeResetLosesGlosses');
+
+ return useSelector((state: AnalysisRootState) =>
+ selectMorphemeResetLosesGlosses(state.analysis, tokenRef),
+ );
+}
+
/**
* Returns the active BCP 47 analysis-language tag from the nearest {@link AnalysisStoreProvider}.
*
diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx
index 9f35bd33..3c22b4af 100644
--- a/src/components/MorphemeBox.tsx
+++ b/src/components/MorphemeBox.tsx
@@ -9,7 +9,7 @@
import type { MorphemeAnalysis, Token } from 'interlinearizer';
import { useLocalizedStrings } from '@papi/frontend/react';
import { PopoverAnchor } from 'platform-bible-react';
-import { useEffect, useState } from 'react';
+import { type MouseEvent, useEffect, useState } from 'react';
import { useMorphemeGlossDispatch, useReportGlossEditing } from './AnalysisStore';
const MORPHEME_GLOSS_STRING_KEYS = [
@@ -92,7 +92,11 @@ export function MorphemeBox({
{morphemes.map((m, i) => {
const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${disabled ? '' : ' tw:cursor-pointer'}${isFormsHovered && !disabled ? ' tw:bg-accent' : ''}`;
const formStyle = { gridColumn: i + 1, gridRow: 1 };
- const handleClick = () => {
+ // preventDefault stops the ancestor