From 167527b09f590272e70de606d24613ba7e287c04 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 12 Nov 2024 10:03:37 -0500 Subject: [PATCH 1/8] [MergeDups] Enable protection override --- .../MergeDupsStep/MergeDragDrop/DragSense.tsx | 5 +++- .../MergeDragDrop/SidebarDragSense.tsx | 5 +++- .../MergeDupsStep/MergeDragDrop/index.tsx | 20 +++++++++++----- .../MergeDupsStep/SaveDeferButtons.tsx | 24 +++++++++++++++++-- .../MergeDuplicates/Redux/MergeDupsActions.ts | 5 ++++ .../MergeDuplicates/Redux/MergeDupsReducer.ts | 10 +++++++- .../Redux/MergeDupsReduxTypes.ts | 4 ++++ 7 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx index fcbe4e8660..9dc16cc087 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx @@ -41,6 +41,9 @@ export default function DragSense(props: DragSenseProps): ReactElement { arraysEqual ); const dispatch = useAppDispatch(); + const overrideProtection = useAppSelector( + (state: StoreState) => state.mergeDuplicateGoal.overrideProtection + ); const sidebar = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar ); @@ -95,7 +98,7 @@ export default function DragSense(props: DragSenseProps): ReactElement { isSenseProtected: props.isProtectedSense, })} index={props.index} - isDragDisabled={props.isOnlySenseInProtectedWord} + isDragDisabled={props.isOnlySenseInProtectedWord && !overrideProtection} > {(provided, snapshot): ReactElement => ( state.mergeDuplicateGoal.overrideProtection + ); return ( {(provided, snapshot): ReactElement => (
state.mergeDuplicateGoal.overrideProtection + ); const sidebarOpen = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar.mergeSenses.length > 1 ); const sidebarProtected = useAppSelector((state: StoreState) => { - const ms = state.mergeDuplicateGoal.tree.sidebar.mergeSenses; - return ms.length && ms[0].protected; + const goal = state.mergeDuplicateGoal; + const ms = goal.tree.sidebar.mergeSenses; + return ms.length && ms[0].protected && !goal.overrideProtection; }); const words = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.words ); - const [senseToDelete, setSenseToDelete] = useState(""); + const [senseToDelete, setSenseToDelete] = useState(""); const { t } = useTranslation(); function handleDrop(res: DropResult): void { const src: MergeTreeReference = JSON.parse(res.draggableId); const srcWordId = res.source.droppableId; const srcWord = words[srcWordId]; - if (srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1) { + if ( + srcWord?.protected && + !overrideProtection && + Object.keys(srcWord.sensesGuids).length === 1 + ) { // Case 0: The final sense of a protected word cannot be moved. return; } else if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. - if (src.isSenseProtected) { + if (src.isSenseProtected && !overrideProtection) { // Case 1a: Cannot delete a protected sense. return; } setSenseToDelete(res.draggableId); } else if (res.combine) { // Case 2: the sense was dropped on another sense. - if (src.isSenseProtected) { + if (src.isSenseProtected && !overrideProtection) { // Case 2a: Cannot merge a protected sense into another sense. if (srcWordId !== res.combine.droppableId) { // The target sense is in a different word, so move instead of combine. diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx index 3ad453b729..d5c4fe672b 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx @@ -1,4 +1,4 @@ -import { Grid } from "@mui/material"; +import { Checkbox, FormControlLabel, Grid } from "@mui/material"; import { ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,14 +7,23 @@ import { deferMerge, mergeAll, setSidebar, + toggleOverrideProtection, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { asyncAdvanceStep } from "goals/Redux/GoalActions"; -import { useAppDispatch } from "rootRedux/hooks"; +import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; +import { StoreState } from "rootRedux/types"; import theme from "types/theme"; export default function SaveDeferButtons(): ReactElement { const dispatch = useAppDispatch(); + const hasProtected = useAppSelector( + (state: StoreState) => state.mergeDuplicateGoal.hasProtected + ); + const overrideProtection = useAppSelector( + (state: StoreState) => state.mergeDuplicateGoal.overrideProtection + ); + const [isDeferring, setIsDeferring] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -64,6 +73,17 @@ export default function SaveDeferButtons(): ReactElement { > {t("buttons.defer")} + {hasProtected && ( + dispatch(toggleOverrideProtection())} + /> + } + label={"Allow deletion of protected words or senses?"} + /> + )} ); diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts index b09a04b158..4b0a762d7b 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts @@ -26,6 +26,7 @@ import { setDataAction, setSidebarAction, setVernacularAction, + toggleOverrideProtectionAction, } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { CombineSenseMergePayload, @@ -94,6 +95,10 @@ export function setVern(payload: SetVernacularPayload): PayloadAction { return setVernacularAction(payload); } +export function toggleOverrideProtection(): Action { + return toggleOverrideProtectionAction(); +} + // Dispatch Functions export function deferMerge() { diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index 0169862abe..e2392dff23 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -1,7 +1,7 @@ import { createSlice } from "@reduxjs/toolkit"; import { v4 } from "uuid"; -import { type Word } from "api/models"; +import { Status, type Word } from "api/models"; import { type MergeTreeReference, type MergeTreeSense, @@ -293,8 +293,10 @@ const mergeDuplicatesSlice = createSlice({ const wordsTree: Hash = {}; const counts: Hash = {}; action.payload.forEach((word: Word) => { + state.hasProtected ||= word.accessibility === Status.Protected; words[word.id] = JSON.parse(JSON.stringify(word)); word.senses.forEach((s, order) => { + state.hasProtected ||= s.accessibility === Status.Protected; senses[s.guid] = convertSenseToMergeTreeSense(s, word.id, order); }); wordsTree[word.id] = convertWordToMergeTreeWord(word); @@ -304,12 +306,17 @@ const mergeDuplicatesSlice = createSlice({ state.tree = { ...defaultTree, words: wordsTree }; state.audio = { ...defaultAudio, counts }; state.mergeWords = []; + state.overrideProtection = false; } }, setVernacularAction: (state, action) => { state.tree.words[action.payload.wordId].vern = action.payload.vern; }, + + toggleOverrideProtectionAction: (state) => { + state.overrideProtection = !state.overrideProtection; + }, }, extraReducers: (builder) => builder.addCase(StoreActionTypes.RESET, () => defaultState), @@ -329,6 +336,7 @@ export const { setDataAction, setSidebarAction, setVernacularAction, + toggleOverrideProtectionAction, } = mergeDuplicatesSlice.actions; export default mergeDuplicatesSlice.reducer; diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts index 95663ebb2e..776a5ab6f6 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts @@ -31,14 +31,18 @@ export interface MergeTreeState { data: MergeData; tree: MergeTree; audio: MergeAudio; + hasProtected: boolean; mergeWords: MergeWords[]; + overrideProtection: boolean; } export const defaultState: MergeTreeState = { data: defaultData, tree: defaultTree, audio: defaultAudio, + hasProtected: false, mergeWords: [], + overrideProtection: false, }; // Action payloads From 7185f48e242cd2fbcb45cd7631b58617f23d7f9a Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Wed, 13 Nov 2024 13:24:47 -0500 Subject: [PATCH 2/8] Reset hasProtected when new words loaded --- src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index e2392dff23..48a4ac439c 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -292,6 +292,7 @@ const mergeDuplicatesSlice = createSlice({ const senses: Hash = {}; const wordsTree: Hash = {}; const counts: Hash = {}; + state.hasProtected = false; action.payload.forEach((word: Word) => { state.hasProtected ||= word.accessibility === Status.Protected; words[word.id] = JSON.parse(JSON.stringify(word)); From e21032cee0fc207418152182854c285352574434 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Wed, 13 Nov 2024 13:47:22 -0500 Subject: [PATCH 3/8] Add reducer test cases for protect override --- .../Redux/tests/MergeDupsReducer.test.tsx | 76 ++++++++++++++----- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx index d4e62a6d01..d0059017cc 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx @@ -1,5 +1,6 @@ import { type Action, type PayloadAction } from "@reduxjs/toolkit"; +import { Status } from "api/models"; import { type MergeTreeReference, type MergeTreeWord, @@ -17,6 +18,7 @@ import { moveSense, orderSense, setData, + toggleOverrideProtection, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import mergeDupStepReducer from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { @@ -572,25 +574,61 @@ describe("MergeDupsReducer", () => { ); }); - test("setWordData", () => { - const wordList = testWordList(); - const treeState = mergeDupStepReducer(undefined, setData(wordList)); - // check if data has all words present - for (const word of wordList) { - const srcWordId = word.id; - expect(Object.keys(treeState.data.words)).toContain(srcWordId); - // check each sense of word - for (const [order, sense] of word.senses.entries()) { - const treeSense = convertSenseToMergeTreeSense(sense, srcWordId, order); - const senses = treeState.data.senses; - expect(Object.values(senses).map((s) => JSON.stringify(s))).toContain( - JSON.stringify(treeSense) - ); - // check that this sense is somewhere in the tree - expect( - getRefByGuid(treeSense.sense.guid, treeState.tree.words) - ).toBeDefined(); + describe("setWordData", () => { + test("with no protected words/senses", () => { + const wordList = testWordList(); + const treeState = mergeDupStepReducer(undefined, setData(wordList)); + // check if data has all words present + for (const word of wordList) { + const srcWordId = word.id; + expect(Object.keys(treeState.data.words)).toContain(srcWordId); + // check each sense of word + for (const [order, sense] of word.senses.entries()) { + const treeSense = convertSenseToMergeTreeSense( + sense, + srcWordId, + order + ); + const senses = treeState.data.senses; + expect(Object.values(senses).map((s) => JSON.stringify(s))).toContain( + JSON.stringify(treeSense) + ); + // check that this sense is somewhere in the tree + expect( + getRefByGuid(treeSense.sense.guid, treeState.tree.words) + ).toBeDefined(); + } } - } + // check that overrideProtection is reset + expect(treeState.overrideProtection).toEqual(false); + // check that hasProtected is false + expect(treeState.hasProtected).toBeFalsy(); + }); + + test("with protected word", () => { + const wordList = testWordList(); + wordList[1].accessibility = Status.Protected; + const treeState = mergeDupStepReducer(undefined, setData(wordList)); + // check that hasProtected is true + expect(treeState.hasProtected).toBeTruthy(); + }); + + test("with protected sense", () => { + const wordList = testWordList(); + wordList.find((w) => w.senses.length)!.senses[0].accessibility = + Status.Protected; + const treeState = mergeDupStepReducer(undefined, setData(wordList)); + // check that hasProtected is true + expect(treeState.hasProtected).toBeTruthy(); + }); + }); + + test("toggleOverrideProtection", () => { + let state = defaultState; + expect(state.overrideProtection).toBeFalsy(); + state = mergeDupStepReducer(state, toggleOverrideProtection()); + expect(state.overrideProtection).toBeTruthy(); + state = mergeDupStepReducer(state, toggleOverrideProtection()); + expect(state.overrideProtection).toBeFalsy(); }); }); From 04400e28df9168a6045ad45f3d7955ed962c842d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 13 Nov 2024 15:08:21 -0500 Subject: [PATCH 4/8] Add project setting for overriding protected words/senses --- Backend.Tests/Models/ProjectTests.cs | 3 +- Backend/Models/Project.cs | 17 +++++-- Backend/Models/User.cs | 4 +- Backend/Repositories/ProjectRepository.cs | 1 + public/locales/en/translation.json | 4 ++ src/api/.openapi-generator/FILES | 2 +- src/api/models/index.ts | 2 +- ...ocomplete-setting.ts => off-on-setting.ts} | 2 +- src/api/models/project.ts | 12 +++-- src/api/models/user.ts | 14 +++--- .../DataEntry/DataEntryTable/index.tsx | 7 ++- .../ProjectSettings/ProjectAutocomplete.tsx | 10 ++-- .../ProjectProtectedOverride.tsx | 48 +++++++++++++++++++ src/components/ProjectSettings/index.tsx | 17 +++++++ .../tests/ProjectAutocomplete.test.tsx | 6 +-- .../ProjectSettings/tests/SettingsTabTypes.ts | 2 + src/components/UserSettings/UserSettings.tsx | 8 ++-- .../MergeDupsStep/SaveDeferButtons.tsx | 6 ++- src/types/project.ts | 5 +- src/types/user.ts | 4 +- 20 files changed, 133 insertions(+), 41 deletions(-) rename src/api/models/{autocomplete-setting.ts => off-on-setting.ts} (93%) create mode 100644 src/components/ProjectSettings/ProjectProtectedOverride.tsx diff --git a/Backend.Tests/Models/ProjectTests.cs b/Backend.Tests/Models/ProjectTests.cs index 59e73a98fd..dd28d3ef42 100644 --- a/Backend.Tests/Models/ProjectTests.cs +++ b/Backend.Tests/Models/ProjectTests.cs @@ -107,7 +107,8 @@ public void TestClone() LiftImported = true, DefinitionsEnabled = true, GrammaticalInfoEnabled = true, - AutocompleteSetting = AutocompleteSetting.On, + AutocompleteSetting = OffOnSetting.On, + ProtectedDataOverrideEnabled = OffOnSetting.Off, SemDomWritingSystem = new("fr", "Français"), VernacularWritingSystem = new("en", "English", "Calibri"), AnalysisWritingSystems = new() { new("es", "Español") }, diff --git a/Backend/Models/Project.cs b/Backend/Models/Project.cs index ba6c3fe6f8..443f289083 100644 --- a/Backend/Models/Project.cs +++ b/Backend/Models/Project.cs @@ -38,7 +38,12 @@ public class Project [Required] [BsonElement("autocompleteSetting")] [BsonRepresentation(BsonType.String)] - public AutocompleteSetting AutocompleteSetting { get; set; } + public OffOnSetting AutocompleteSetting { get; set; } + + [Required] + [BsonElement("protectedDataOverrideEnabled")] + [BsonRepresentation(BsonType.String)] + public OffOnSetting ProtectedDataOverrideEnabled { get; set; } [Required] [BsonElement("semDomWritingSystem")] @@ -92,7 +97,8 @@ public Project() LiftImported = false; DefinitionsEnabled = false; GrammaticalInfoEnabled = false; - AutocompleteSetting = AutocompleteSetting.On; + AutocompleteSetting = OffOnSetting.On; + ProtectedDataOverrideEnabled = OffOnSetting.Off; SemDomWritingSystem = new(); VernacularWritingSystem = new(); AnalysisWritingSystems = new(); @@ -117,6 +123,7 @@ public Project Clone() DefinitionsEnabled = DefinitionsEnabled, GrammaticalInfoEnabled = GrammaticalInfoEnabled, AutocompleteSetting = AutocompleteSetting, + ProtectedDataOverrideEnabled = ProtectedDataOverrideEnabled, SemDomWritingSystem = SemDomWritingSystem.Clone(), VernacularWritingSystem = VernacularWritingSystem.Clone(), AnalysisWritingSystems = AnalysisWritingSystems.Select(ws => ws.Clone()).ToList(), @@ -140,6 +147,7 @@ public bool ContentEquals(Project other) other.DefinitionsEnabled == DefinitionsEnabled && other.GrammaticalInfoEnabled == GrammaticalInfoEnabled && other.AutocompleteSetting.Equals(AutocompleteSetting) && + other.ProtectedDataOverrideEnabled.Equals(ProtectedDataOverrideEnabled) && other.SemDomWritingSystem.Equals(SemDomWritingSystem) && other.VernacularWritingSystem.Equals(VernacularWritingSystem) && @@ -186,11 +194,12 @@ public override int GetHashCode() var hash = new HashCode(); hash.Add(Id); hash.Add(Name); + hash.Add(IsActive); hash.Add(LiftImported); hash.Add(DefinitionsEnabled); hash.Add(GrammaticalInfoEnabled); - hash.Add(IsActive); hash.Add(AutocompleteSetting); + hash.Add(ProtectedDataOverrideEnabled); hash.Add(SemDomWritingSystem); hash.Add(VernacularWritingSystem); hash.Add(AnalysisWritingSystems); @@ -329,7 +338,7 @@ public UserCreatedProject() } } - public enum AutocompleteSetting + public enum OffOnSetting { Off, On diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index d6407b1416..1814dd8758 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -72,7 +72,7 @@ public class User [Required] [BsonElement("glossSuggestion")] [BsonRepresentation(BsonType.String)] - public AutocompleteSetting GlossSuggestion { get; set; } + public OffOnSetting GlossSuggestion { get; set; } [Required] [BsonElement("token")] @@ -98,7 +98,7 @@ public User() Password = ""; Username = ""; UILang = ""; - GlossSuggestion = AutocompleteSetting.On; + GlossSuggestion = OffOnSetting.On; Token = ""; IsAdmin = false; WorkedProjects = new(); diff --git a/Backend/Repositories/ProjectRepository.cs b/Backend/Repositories/ProjectRepository.cs index 40652f550c..3baa99b512 100644 --- a/Backend/Repositories/ProjectRepository.cs +++ b/Backend/Repositories/ProjectRepository.cs @@ -99,6 +99,7 @@ public async Task Update(string projectId, Project project) .Set(x => x.DefinitionsEnabled, project.DefinitionsEnabled) .Set(x => x.GrammaticalInfoEnabled, project.GrammaticalInfoEnabled) .Set(x => x.AutocompleteSetting, project.AutocompleteSetting) + .Set(x => x.ProtectedDataOverrideEnabled, project.ProtectedDataOverrideEnabled) .Set(x => x.SemDomWritingSystem, project.SemDomWritingSystem) .Set(x => x.VernacularWritingSystem, project.VernacularWritingSystem) .Set(x => x.AnalysisWritingSystems, project.AnalysisWritingSystems) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 7e757db4b4..181bf97dd1 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -265,6 +265,10 @@ "on": "On", "hint": "In Data Entry, suggest existing Vernaculars similar to the Vernacular being typed." }, + "protectedDataOverride": { + "hint": "In Merge Duplicates, allow overriding protection of protected words and senses.", + "label": "Protected Data Override" + }, "invite": { "inviteByEmailLabel": "Invite by Email", "userExists": "This user is already registered.", diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 873eb5b3b5..cf9d132ea8 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -21,7 +21,6 @@ common.ts configuration.ts git_push.sh index.ts -models/autocomplete-setting.ts models/banner-type.ts models/chart-root-data.ts models/consent-type.ts @@ -42,6 +41,7 @@ models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts models/note.ts +models/off-on-setting.ts models/password-reset-data.ts models/password-reset-request-data.ts models/permission.ts diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 5b140bd273..dc5992c9b2 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,4 +1,3 @@ -export * from "./autocomplete-setting"; export * from "./banner-type"; export * from "./chart-root-data"; export * from "./consent-type"; @@ -18,6 +17,7 @@ export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; export * from "./note"; +export * from "./off-on-setting"; export * from "./password-reset-data"; export * from "./password-reset-request-data"; export * from "./permission"; diff --git a/src/api/models/autocomplete-setting.ts b/src/api/models/off-on-setting.ts similarity index 93% rename from src/api/models/autocomplete-setting.ts rename to src/api/models/off-on-setting.ts index b54c9ab6fa..a41171e274 100644 --- a/src/api/models/autocomplete-setting.ts +++ b/src/api/models/off-on-setting.ts @@ -17,7 +17,7 @@ * @export * @enum {string} */ -export enum AutocompleteSetting { +export enum OffOnSetting { Off = "Off", On = "On", } diff --git a/src/api/models/project.ts b/src/api/models/project.ts index 0958b4d28c..898556b259 100644 --- a/src/api/models/project.ts +++ b/src/api/models/project.ts @@ -12,9 +12,9 @@ * Do not edit the class manually. */ -import { AutocompleteSetting } from "./autocomplete-setting"; import { CustomField } from "./custom-field"; import { EmailInvite } from "./email-invite"; +import { OffOnSetting } from "./off-on-setting"; import { SemanticDomainFull } from "./semantic-domain-full"; import { WritingSystem } from "./writing-system"; @@ -62,10 +62,16 @@ export interface Project { grammaticalInfoEnabled: boolean; /** * - * @type {AutocompleteSetting} + * @type {OffOnSetting} * @memberof Project */ - autocompleteSetting: AutocompleteSetting; + autocompleteSetting: OffOnSetting; + /** + * + * @type {OffOnSetting} + * @memberof Project + */ + protectedDataOverrideEnabled: OffOnSetting; /** * * @type {WritingSystem} diff --git a/src/api/models/user.ts b/src/api/models/user.ts index fef3669aef..b88fa4d43a 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -12,7 +12,7 @@ * Do not edit the class manually. */ -import { AutocompleteSetting } from "./autocomplete-setting"; +import { OffOnSetting } from "./off-on-setting"; /** * @@ -100,20 +100,20 @@ export interface User { uiLang?: string | null; /** * - * @type {string} + * @type {OffOnSetting} * @memberof User */ - token: string; + glossSuggestion: OffOnSetting; /** * - * @type {boolean} + * @type {string} * @memberof User */ - isAdmin: boolean; + token: string; /** * - * @type {AutocompleteSetting} + * @type {boolean} * @memberof User */ - glossSuggestion: AutocompleteSetting; + isAdmin: boolean; } diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index ce9f8325cc..3d20bda5dd 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -17,8 +17,8 @@ import { useTranslation } from "react-i18next"; import { v4 } from "uuid"; import { - AutocompleteSetting, Note, + OffOnSetting, Pronunciation, SemanticDomain, SemanticDomainTreeNode, @@ -250,8 +250,7 @@ export default function DataEntryTable( ); const suggestVerns = useAppSelector( (state: StoreState) => - state.currentProjectState.project.autocompleteSetting === - AutocompleteSetting.On + state.currentProjectState.project.autocompleteSetting === OffOnSetting.On ); const vernacularLang = useAppSelector( (state: StoreState) => @@ -272,7 +271,7 @@ export default function DataEntryTable( const spellChecker = useContext(SpellCheckerContext); useEffect(() => { spellChecker.updateLang( - getCurrentUser()?.glossSuggestion === AutocompleteSetting.Off + getCurrentUser()?.glossSuggestion === OffOnSetting.Off ? undefined : analysisLang.bcp47 ); diff --git a/src/components/ProjectSettings/ProjectAutocomplete.tsx b/src/components/ProjectSettings/ProjectAutocomplete.tsx index 1c0f4c7402..5890af880f 100644 --- a/src/components/ProjectSettings/ProjectAutocomplete.tsx +++ b/src/components/ProjectSettings/ProjectAutocomplete.tsx @@ -3,7 +3,7 @@ import { Grid, MenuItem, Select, Tooltip } from "@mui/material"; import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { AutocompleteSetting } from "api/models"; +import { OffOnSetting } from "api/models"; import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; export default function ProjectAutocomplete( @@ -12,7 +12,7 @@ export default function ProjectAutocomplete( const { t } = useTranslation(); const updateAutocompleteSetting = async ( - autocompleteSetting: AutocompleteSetting + autocompleteSetting: OffOnSetting ): Promise => { await props.setProject({ ...props.project, autocompleteSetting }); }; @@ -24,13 +24,13 @@ export default function ProjectAutocomplete( variant="standard" value={props.project.autocompleteSetting} onChange={(e) => - updateAutocompleteSetting(e.target.value as AutocompleteSetting) + updateAutocompleteSetting(e.target.value as OffOnSetting) } > - + {t("projectSettings.autocomplete.off")} - + {t("projectSettings.autocomplete.on")} diff --git a/src/components/ProjectSettings/ProjectProtectedOverride.tsx b/src/components/ProjectSettings/ProjectProtectedOverride.tsx new file mode 100644 index 0000000000..89b03f82b9 --- /dev/null +++ b/src/components/ProjectSettings/ProjectProtectedOverride.tsx @@ -0,0 +1,48 @@ +import { HelpOutline } from "@mui/icons-material"; +import { Grid, MenuItem, Select, Tooltip } from "@mui/material"; +import { type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { OffOnSetting } from "api/models"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; + +export default function ProjectProtectedOverride( + props: ProjectSettingProps +): ReactElement { + const { t } = useTranslation(); + + const updateProtectOverrideSetting = async ( + protectedDataOverrideEnabled: OffOnSetting + ): Promise => { + await props.setProject({ ...props.project, protectedDataOverrideEnabled }); + }; + + return ( + + + + + + + + + + + ); +} diff --git a/src/components/ProjectSettings/index.tsx b/src/components/ProjectSettings/index.tsx index c21fa820fe..382747a3d1 100644 --- a/src/components/ProjectSettings/index.tsx +++ b/src/components/ProjectSettings/index.tsx @@ -10,6 +10,7 @@ import { People, PersonAdd, RecordVoiceOver, + RemoveModerator, Settings, Sms, } from "@mui/icons-material"; @@ -53,6 +54,7 @@ import ProjectLanguages, { SemanticDomainLanguage, } from "components/ProjectSettings/ProjectLanguages"; import ProjectName from "components/ProjectSettings/ProjectName"; +import ProjectProtectedOverride from "components/ProjectSettings/ProjectProtectedOverride"; import ProjectSchedule from "components/ProjectSettings/ProjectSchedule"; import ProjectSelect from "components/ProjectSettings/ProjectSelect"; import ActiveProjectUsers from "components/ProjectUsers/ActiveProjectUsers"; @@ -80,6 +82,7 @@ export enum Setting { Import = "SettingImport", Languages = "SettingLanguages", Name = "SettingName", + ProtectOverride = "SettingProtectOverride", Schedule = "SettingSchedule", Speakers = "SettingSpeakers", UserAdd = "SettingUserAdd", @@ -176,6 +179,20 @@ export default function ProjectSettingsComponent(): ReactElement { /> )} + {/* Protected data override toggle */} + {permissions.includes(Permission.DeleteEditSettingsAndUsers) && ( + } + title={t("projectSettings.protectedDataOverride.label")} + body={ + + } + /> + )} + {/* Archive project */} {permissions.includes(Permission.Archive) && ( { await renderer.act(async () => selectChange({ target: { value: "Off" } })); expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, - autocompleteSetting: AutocompleteSetting.Off, + autocompleteSetting: OffOnSetting.Off, }); await renderer.act(async () => selectChange({ target: { value: "On" } })); expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, - autocompleteSetting: AutocompleteSetting.On, + autocompleteSetting: OffOnSetting.On, }); }); }); diff --git a/src/components/ProjectSettings/tests/SettingsTabTypes.ts b/src/components/ProjectSettings/tests/SettingsTabTypes.ts index f98a47a9a6..e5e3f3f830 100644 --- a/src/components/ProjectSettings/tests/SettingsTabTypes.ts +++ b/src/components/ProjectSettings/tests/SettingsTabTypes.ts @@ -8,6 +8,7 @@ const settingsByTab: Record = { Setting.Archive, Setting.Autocomplete, Setting.Name, + Setting.ProtectOverride, ], [ProjectSettingsTab.ImportExport]: [Setting.Export, Setting.Import], [ProjectSettingsTab.Languages]: [Setting.Languages], @@ -34,6 +35,7 @@ const settingsByPermission: Record = { Setting.DomainsCustom, Setting.Languages, Setting.Name, + Setting.ProtectOverride, Setting.Speakers, Setting.UserAdd, Setting.Users, diff --git a/src/components/UserSettings/UserSettings.tsx b/src/components/UserSettings/UserSettings.tsx index ecf98a692c..8d6f69e0f5 100644 --- a/src/components/UserSettings/UserSettings.tsx +++ b/src/components/UserSettings/UserSettings.tsx @@ -15,7 +15,7 @@ import { FormEvent, Fragment, ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; import { show } from "vanilla-cookieconsent"; -import { AutocompleteSetting, User } from "api/models"; +import { OffOnSetting, User } from "api/models"; import { isEmailTaken, updateUser } from "backend"; import { getAvatar, getCurrentUser } from "backend/localStorage"; import { asyncLoadSemanticDomains } from "components/Project/ProjectActions"; @@ -252,15 +252,15 @@ export function UserSettings(props: { data-testid={UserSettingsIds.SelectGlossSuggestion} id={UserSettingsIds.SelectGlossSuggestion} onChange={(e) => - setGlossSuggestion(e.target.value as AutocompleteSetting) + setGlossSuggestion(e.target.value as OffOnSetting) } value={glossSuggestion} variant="standard" > - + {t("projectSettings.autocomplete.off")} - + {t("projectSettings.autocomplete.on")} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx index d5c4fe672b..47a3da1897 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx @@ -2,6 +2,7 @@ import { Checkbox, FormControlLabel, Grid } from "@mui/material"; import { ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; +import { OffOnSetting } from "api/models"; import { LoadingButton } from "components/Buttons"; import { deferMerge, @@ -18,7 +19,10 @@ export default function SaveDeferButtons(): ReactElement { const dispatch = useAppDispatch(); const hasProtected = useAppSelector( - (state: StoreState) => state.mergeDuplicateGoal.hasProtected + (state: StoreState) => + state.mergeDuplicateGoal.hasProtected && + state.currentProjectState.project.protectedDataOverrideEnabled == + OffOnSetting.On ); const overrideProtection = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.overrideProtection diff --git a/src/types/project.ts b/src/types/project.ts index c0d70abf89..0bd6b05350 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,4 +1,4 @@ -import { AutocompleteSetting, ConsentType, Project, Speaker } from "api/models"; +import { ConsentType, OffOnSetting, Project, Speaker } from "api/models"; import { newWritingSystem } from "types/writingSystem"; import { randomIntString } from "utilities/utilities"; @@ -10,6 +10,8 @@ export function newProject(name = ""): Project { liftImported: false, definitionsEnabled: false, grammaticalInfoEnabled: false, + autocompleteSetting: OffOnSetting.On, + protectedDataOverrideEnabled: OffOnSetting.Off, semanticDomains: [], semDomWritingSystem: newWritingSystem(), vernacularWritingSystem: newWritingSystem(), @@ -17,7 +19,6 @@ export function newProject(name = ""): Project { validCharacters: [], rejectedCharacters: [], inviteTokens: [], - autocompleteSetting: AutocompleteSetting.On, }; } diff --git a/src/types/user.ts b/src/types/user.ts index 29d6d0177c..6ea2534882 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,4 +1,4 @@ -import { AutocompleteSetting, User } from "api/models"; +import { OffOnSetting, User } from "api/models"; export function newUser(name = "", username = "", password = ""): User { return { @@ -12,7 +12,7 @@ export function newUser(name = "", username = "", password = ""): User { phone: "", projectRoles: {}, workedProjects: {}, - glossSuggestion: AutocompleteSetting.On, + glossSuggestion: OffOnSetting.On, token: "", isAdmin: false, }; From 9abdab19c34c41a72c29f2ac76cf809401af5023 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Thu, 14 Nov 2024 09:27:09 -0500 Subject: [PATCH 5/8] Add pop-up confirmation for overriding protected senses --- public/locales/en/translation.json | 7 +- .../MergeDupsStep/MergeDragDrop/index.tsx | 84 +++++++++++++++---- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 181bf97dd1..e4b72c0b54 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -414,10 +414,11 @@ "delete": "Delete sense", "deleteDialog": "Delete this sense?", "protectedSense": "This sense was imported with data that The Combine doesn't handle.", - "protectedSenseInfo": "This sense cannot be deleted or dropped into another sense. You may still move it to another word or drop other senses into this one to merge them.", + "protectedSenseInfo": "This sense cannot be safely deleted or dropped into another sense. You may still move it to another word or drop other senses into this one to merge them.", "protectedWord": "This word was imported with data that The Combine doesn't handle.", - "protectedWordInfo": "To prevent deletion, the final sense of this word cannot be removed.", - "protectedData": "Protected data: {{ val }}" + "protectedWordInfo": "To prevent deletion, the final sense of this word cannot be safely removed.", + "protectedData": "Protected data: {{ val }}", + "protectedOverrideWarning": "The following data will be lost: {{ val }}" }, "protectReason": { "annotations": "annotations", diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx index c75fef5d21..1fde4e4438 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx @@ -40,9 +40,33 @@ export default function MergeDragDrop(): ReactElement { (state: StoreState) => state.mergeDuplicateGoal.tree.words ); - const [senseToDelete, setSenseToDelete] = useState(""); + const [protectedDataText, setProtectedDataText] = useState(""); + const [protectedDest, setProtectedDest] = useState< + MergeTreeReference | undefined + >(); + const [protectedSrc, setProtectedSrc] = useState< + MergeTreeReference | undefined + >(); + const [srcToDelete, setSrcToDelete] = useState< + MergeTreeReference | undefined + >(); + const { t } = useTranslation(); + function startOverrideProtectedData( + protectedData: string, + src: MergeTreeReference, + dest?: MergeTreeReference + ): void { + setProtectedDest(dest); + setProtectedSrc(src); + setProtectedDataText( + t("mergeDups.helpText.protectedOverrideWarning", { + val: protectedData, + }) + ); + } + function handleDrop(res: DropResult): void { const src: MergeTreeReference = JSON.parse(res.draggableId); const srcWordId = res.source.droppableId; @@ -56,17 +80,27 @@ export default function MergeDragDrop(): ReactElement { return; } else if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. - if (src.isSenseProtected && !overrideProtection) { + if (src.isSenseProtected) { // Case 1a: Cannot delete a protected sense. + if (overrideProtection) { + // ... unless protection override is active and user confirms. + startOverrideProtectedData("TODO: extract data", src); + } return; } - setSenseToDelete(res.draggableId); + setSrcToDelete(src); } else if (res.combine) { + const combineRef: MergeTreeReference = JSON.parse( + res.combine.draggableId + ); // Case 2: the sense was dropped on another sense. - if (src.isSenseProtected && !overrideProtection) { + if (src.isSenseProtected) { // Case 2a: Cannot merge a protected sense into another sense. - if (srcWordId !== res.combine.droppableId) { - // The target sense is in a different word, so move instead of combine. + if (overrideProtection) { + // ... unless protection override is active and user confirms. + startOverrideProtectedData("TODO: extract data", src, combineRef); + } else if (srcWordId !== res.combine.droppableId) { + // Otherwise, if target sense is in different word, move instead of combine. dispatch( moveSense({ src, @@ -77,13 +111,11 @@ export default function MergeDragDrop(): ReactElement { } return; } - const combineRef: MergeTreeReference = JSON.parse( - res.combine.draggableId - ); if (combineRef.order !== undefined) { // Case 2b: If the target is a sidebar sub-sense, it cannot receive a combine. return; } + // TODO: handle override case when sense is last in protected word dispatch(combineSense({ src, dest: combineRef })); } else if (res.destination) { const destWordId = res.destination.droppableId; @@ -106,6 +138,7 @@ export default function MergeDragDrop(): ReactElement { (destOrder === 0 && src.order !== undefined && sidebarProtected) ) { // If the sense wasn't moved or was moved within the sidebar above a protected sense, do nothing. + // TODO: Handle override case when moving above protected sense in sidebar return; } dispatch(orderSense({ src, destOrder })); @@ -113,9 +146,24 @@ export default function MergeDragDrop(): ReactElement { } } - function performDelete(): void { - dispatch(deleteSense(JSON.parse(senseToDelete))); - setSenseToDelete(""); + function onConfirmDelete(): void { + if (srcToDelete) { + dispatch(deleteSense(srcToDelete)); + setSrcToDelete(undefined); + } + } + + function onConfirmOverride(): void { + if (protectedSrc) { + if (protectedDest) { + dispatch(combineSense({ src: protectedSrc, dest: protectedDest })); + } else { + dispatch(deleteSense(protectedSrc)); + } + setProtectedSrc(undefined); + } + setProtectedDest(undefined); + setProtectedDataText(""); } function renderSidebar(): ReactElement { @@ -178,10 +226,16 @@ export default function MergeDragDrop(): ReactElement { {renderSidebar()} setProtectedDataText("")} + handleConfirm={onConfirmOverride} + /> + setSenseToDelete("")} - handleConfirm={performDelete} + handleCancel={() => setSrcToDelete(undefined)} + handleConfirm={onConfirmDelete} /> From c5e9cc6ff46a71d0b62f51ca5907c31e3d592011 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Fri, 13 Dec 2024 17:42:46 -0500 Subject: [PATCH 6/8] Extract protect-reason text functions --- .../MergeDupsStep/MergeDragDrop/DropWord.tsx | 63 +-------- .../MergeDupsStep/MergeDragDrop/index.tsx | 27 ++-- .../MergeDupsStep/SenseCardContent.tsx | 71 +--------- .../MergeDupsStep/protectReasonUtils.ts | 133 ++++++++++++++++++ .../MergeDuplicates/MergeDupsTreeTypes.ts | 3 + 5 files changed, 160 insertions(+), 137 deletions(-) create mode 100644 src/goals/MergeDuplicates/MergeDupsStep/protectReasonUtils.ts diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index 2d80007ec7..f79b4c4a28 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -11,7 +11,7 @@ import { type ReactElement } from "react"; import { Droppable } from "react-beautiful-dnd"; import { useTranslation } from "react-i18next"; -import { type Flag, type ProtectReason, ReasonType } from "api/models"; +import { type Flag } from "api/models"; import { FlagButton, IconButtonWithTooltip, @@ -20,6 +20,7 @@ import { import MultilineTooltipTitle from "components/MultilineTooltipTitle"; import { AudioSummary } from "components/WordCard"; import DragSense from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense"; +import { protectReasonsText } from "goals/MergeDuplicates/MergeDupsStep/protectReasonUtils"; import { type MergeTreeWord } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { flagWord, @@ -164,68 +165,10 @@ export function DropWordCardHeader(
); - const reasonText = (reason: ProtectReason): string => { - // Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftEntry entry) - switch (reason.type) { - case ReasonType.Annotations: - return t("mergeDups.protectReason.annotations"); - case ReasonType.Etymologies: - return t("mergeDups.protectReason.etymologies"); - case ReasonType.Field: - return t("mergeDups.protectReason.field", { val: reason.value }); - case ReasonType.NoteWithType: - return t("mergeDups.protectReason.noteWithType", { val: reason.value }); - case ReasonType.Notes: - return t("mergeDups.protectReason.notesWord"); - case ReasonType.Relations: - return t("mergeDups.protectReason.relations"); - case ReasonType.Trait: - return reason.value ?? "(unknown trait)"; - case ReasonType.TraitDialectLabels: - return t("mergeDups.protectReason.traitDialectLabels", { - val: reason.value, - }); - case ReasonType.TraitDoNotPublishIn: - return t("mergeDups.protectReason.traitDoNotPublishIn", { - val: reason.value, - }); - case ReasonType.TraitDoNotUseForParsing: - return t("mergeDups.protectReason.traitDoNotUseForParsing", { - val: reason.value, - }); - case ReasonType.TraitEntryType: - return t("mergeDups.protectReason.traitEntryType", { - val: reason.value, - }); - case ReasonType.TraitExcludeAsHeadword: - return t("mergeDups.protectReason.traitExcludeAsHeadword"); - case ReasonType.TraitMinorEntryCondition: - return t("mergeDups.protectReason.traitMinorEntryCondition", { - val: reason.value, - }); - case ReasonType.TraitMorphType: - return t("mergeDups.protectReason.traitMorphType", { - val: reason.value, - }); - case ReasonType.TraitPublishIn: - return t("mergeDups.protectReason.traitPublishIn", { - val: reason.value, - }); - case ReasonType.Variants: - return t("mergeDups.protectReason.variants"); - default: - throw new Error(); - } - }; - const tooltipTexts = [t("mergeDups.helpText.protectedWord")]; const reasons = words[props.wordId]?.protectReasons; if (reasons?.length) { - tooltipTexts.push( - t("mergeDups.helpText.protectedData", { - val: reasons.map(reasonText).join("; "), - }) - ); + tooltipTexts.push(protectReasonsText(t, reasons, [])); } tooltipTexts.push(t("mergeDups.helpText.protectedWordInfo")); diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx index 1fde4e4438..ce767477f1 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx @@ -5,10 +5,12 @@ import { DragDropContext, Droppable, DropResult } from "react-beautiful-dnd"; import { useTranslation } from "react-i18next"; import { v4 } from "uuid"; +import { type ProtectReason } from "api/models"; import { appBarHeight } from "components/AppBar/AppBarTypes"; import { CancelConfirmDialog } from "components/Dialogs"; import DropWord from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord"; import SidebarDrop from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop"; +import { protectReasonsText } from "goals/MergeDuplicates/MergeDupsStep/protectReasonUtils"; import { MergeTreeReference } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { combineSense, @@ -27,6 +29,9 @@ export default function MergeDragDrop(): ReactElement { const overrideProtection = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.overrideProtection ); + const senses = useAppSelector( + (state: StoreState) => state.mergeDuplicateGoal.data.senses + ); const sidebarOpen = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar.mergeSenses.length > 1 @@ -71,20 +76,24 @@ export default function MergeDragDrop(): ReactElement { const src: MergeTreeReference = JSON.parse(res.draggableId); const srcWordId = res.source.droppableId; const srcWord = words[srcWordId]; - if ( - srcWord?.protected && - !overrideProtection && - Object.keys(srcWord.sensesGuids).length === 1 - ) { + const senseReasons = senses[src.mergeSenseId].sense.protectReasons ?? []; + let wordReasons: ProtectReason[] = []; + if (srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1) { // Case 0: The final sense of a protected word cannot be moved. - return; - } else if (res.destination?.droppableId === trashId) { + if (overrideProtection) { + wordReasons = srcWord.protectReasons ?? []; + } else { + return; + } + } + const reasonsText = protectReasonsText(t, wordReasons, senseReasons); + if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. if (src.isSenseProtected) { // Case 1a: Cannot delete a protected sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData("TODO: extract data", src); + startOverrideProtectedData(reasonsText, src); } return; } @@ -98,7 +107,7 @@ export default function MergeDragDrop(): ReactElement { // Case 2a: Cannot merge a protected sense into another sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData("TODO: extract data", src, combineRef); + startOverrideProtectedData(reasonsText, src, combineRef); } else if (srcWordId !== res.combine.droppableId) { // Otherwise, if target sense is in different word, move instead of combine. dispatch( diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx index 252e431053..7d402ca996 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx @@ -3,17 +3,12 @@ import { CardContent, IconButton } from "@mui/material"; import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { - GramCatGroup, - type ProtectReason, - ReasonType, - type Sense, - Status, -} from "api/models"; +import { GramCatGroup, type Sense, Status } from "api/models"; import { IconButtonWithTooltip, PartOfSpeechButton } from "components/Buttons"; import MultilineTooltipTitle from "components/MultilineTooltipTitle"; import DomainChipsGrid from "components/WordCard/DomainChipsGrid"; import SenseCardText from "components/WordCard/SenseCardText"; +import { protectReasonsText } from "goals/MergeDuplicates/MergeDupsStep/protectReasonUtils"; import { combineSenses } from "goals/MergeDuplicates/Redux/reducerUtilities"; interface SenseCardContentProps { @@ -41,72 +36,12 @@ export default function SenseCardContent( a.id.localeCompare(b.id) ); - const reasonText = (reason: ProtectReason): string => { - // Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) - switch (reason.type) { - case ReasonType.Annotations: - return t("mergeDups.protectReason.annotations"); - case ReasonType.Examples: - return t("mergeDups.protectReason.examples"); - case ReasonType.Field: - return t("mergeDups.protectReason.field", { val: reason.value }); - case ReasonType.GramInfoTrait: - return t("mergeDups.protectReason.gramInfoTrait", { - val: reason.value, - }); - case ReasonType.Illustrations: - return t("mergeDups.protectReason.illustrations"); - case ReasonType.Notes: - return t("mergeDups.protectReason.notesSense"); - case ReasonType.Relations: - return t("mergeDups.protectReason.relations"); - case ReasonType.Reversals: - return t("mergeDups.protectReason.reversal", { val: reason.value }); - case ReasonType.Subsenses: - return t("mergeDups.protectReason.subsenses"); - case ReasonType.Trait: - return reason.value ?? "(unknown trait)"; - case ReasonType.TraitAnthroCode: - return t("mergeDups.protectReason.traitAnthroCode", { - val: reason.value, - }); - case ReasonType.TraitDomainType: - return t("mergeDups.protectReason.traitDomainType", { - val: reason.value, - }); - case ReasonType.TraitDoNotPublishIn: - return t("mergeDups.protectReason.traitDoNotPublishIn", { - val: reason.value, - }); - case ReasonType.TraitPublishIn: - return t("mergeDups.protectReason.traitPublishIn", { - val: reason.value, - }); - case ReasonType.TraitSenseType: - return t("mergeDups.protectReason.traitSenseType", { - val: reason.value, - }); - case ReasonType.TraitStatus: - return t("mergeDups.protectReason.traitStatus", { val: reason.value }); - case ReasonType.TraitUsageType: - return t("mergeDups.protectReason.traitUsageType", { - val: reason.value, - }); - default: - throw new Error(); - } - }; - const protectedWarning = !props.sidebar && sense.accessibility === Status.Protected; const tooltipTexts = [t("mergeDups.helpText.protectedSense")]; const reasons = sense.protectReasons; if (reasons?.length) { - tooltipTexts.push( - t("mergeDups.helpText.protectedData", { - val: reasons.map(reasonText).join("; "), - }) - ); + tooltipTexts.push(protectReasonsText(t, [], reasons)); } tooltipTexts.push(t("mergeDups.helpText.protectedSenseInfo")); diff --git a/src/goals/MergeDuplicates/MergeDupsStep/protectReasonUtils.ts b/src/goals/MergeDuplicates/MergeDupsStep/protectReasonUtils.ts new file mode 100644 index 0000000000..17b3ac9657 --- /dev/null +++ b/src/goals/MergeDuplicates/MergeDupsStep/protectReasonUtils.ts @@ -0,0 +1,133 @@ +import { TFunction } from "i18next"; + +import { ProtectReason, ReasonType } from "api/models"; + +const sep = "; "; + +export function protectReasonsText( + t: TFunction<"translation", undefined>, + wordReasons: ProtectReason[], + senseReasons: ProtectReason[] +): string { + const wordTexts = wordReasons.map((r) => wordReasonText(t, r)); + const senseTexts = senseReasons.map((r) => senseReasonText(t, r)); + return t("mergeDups.helpText.protectedData", { + val: [...wordTexts, ...senseTexts].join(sep), + }); +} + +/** Cases match Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) */ +function senseReasonText( + t: TFunction<"translation", undefined>, + reason: ProtectReason +): string { + switch (reason.type) { + case ReasonType.Annotations: + return t("mergeDups.protectReason.annotations"); + case ReasonType.Examples: + return t("mergeDups.protectReason.examples"); + case ReasonType.Field: + return t("mergeDups.protectReason.field", { val: reason.value }); + case ReasonType.GramInfoTrait: + return t("mergeDups.protectReason.gramInfoTrait", { + val: reason.value, + }); + case ReasonType.Illustrations: + return t("mergeDups.protectReason.illustrations"); + case ReasonType.Notes: + return t("mergeDups.protectReason.notesSense"); + case ReasonType.Relations: + return t("mergeDups.protectReason.relations"); + case ReasonType.Reversals: + return t("mergeDups.protectReason.reversal", { val: reason.value }); + case ReasonType.Subsenses: + return t("mergeDups.protectReason.subsenses"); + case ReasonType.Trait: + return reason.value ?? "(unknown trait)"; + case ReasonType.TraitAnthroCode: + return t("mergeDups.protectReason.traitAnthroCode", { + val: reason.value, + }); + case ReasonType.TraitDomainType: + return t("mergeDups.protectReason.traitDomainType", { + val: reason.value, + }); + case ReasonType.TraitDoNotPublishIn: + return t("mergeDups.protectReason.traitDoNotPublishIn", { + val: reason.value, + }); + case ReasonType.TraitPublishIn: + return t("mergeDups.protectReason.traitPublishIn", { + val: reason.value, + }); + case ReasonType.TraitSenseType: + return t("mergeDups.protectReason.traitSenseType", { + val: reason.value, + }); + case ReasonType.TraitStatus: + return t("mergeDups.protectReason.traitStatus", { val: reason.value }); + case ReasonType.TraitUsageType: + return t("mergeDups.protectReason.traitUsageType", { + val: reason.value, + }); + default: + throw new Error(); + } +} + +/** Cases match Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftEntry entry) */ +function wordReasonText( + t: TFunction<"translation", undefined>, + reason: ProtectReason +): string { + switch (reason.type) { + case ReasonType.Annotations: + return t("mergeDups.protectReason.annotations"); + case ReasonType.Etymologies: + return t("mergeDups.protectReason.etymologies"); + case ReasonType.Field: + return t("mergeDups.protectReason.field", { val: reason.value }); + case ReasonType.NoteWithType: + return t("mergeDups.protectReason.noteWithType", { val: reason.value }); + case ReasonType.Notes: + return t("mergeDups.protectReason.notesWord"); + case ReasonType.Relations: + return t("mergeDups.protectReason.relations"); + case ReasonType.Trait: + return reason.value ?? "(unknown trait)"; + case ReasonType.TraitDialectLabels: + return t("mergeDups.protectReason.traitDialectLabels", { + val: reason.value, + }); + case ReasonType.TraitDoNotPublishIn: + return t("mergeDups.protectReason.traitDoNotPublishIn", { + val: reason.value, + }); + case ReasonType.TraitDoNotUseForParsing: + return t("mergeDups.protectReason.traitDoNotUseForParsing", { + val: reason.value, + }); + case ReasonType.TraitEntryType: + return t("mergeDups.protectReason.traitEntryType", { + val: reason.value, + }); + case ReasonType.TraitExcludeAsHeadword: + return t("mergeDups.protectReason.traitExcludeAsHeadword"); + case ReasonType.TraitMinorEntryCondition: + return t("mergeDups.protectReason.traitMinorEntryCondition", { + val: reason.value, + }); + case ReasonType.TraitMorphType: + return t("mergeDups.protectReason.traitMorphType", { + val: reason.value, + }); + case ReasonType.TraitPublishIn: + return t("mergeDups.protectReason.traitPublishIn", { + val: reason.value, + }); + case ReasonType.Variants: + return t("mergeDups.protectReason.variants"); + default: + throw new Error(); + } +} diff --git a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts index c27d7a0fcd..38b06022ae 100644 --- a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts @@ -3,6 +3,7 @@ import { v4 } from "uuid"; import { type Flag, type Note, + type ProtectReason, type Sense, Status, type Word, @@ -37,6 +38,7 @@ export interface MergeTreeWord { flag: Flag; note: Note; protected: boolean; + protectReasons?: ProtectReason[] | null; audioCount: number; } @@ -89,6 +91,7 @@ export function convertWordToMergeTreeWord(word: Word): MergeTreeWord { mergeTreeWord.flag = { ...word.flag }; mergeTreeWord.note = { ...word.note }; mergeTreeWord.protected = word.accessibility === Status.Protected; + mergeTreeWord.protectReasons = word.protectReasons; mergeTreeWord.audioCount = word.audio.length; return mergeTreeWord; } From 72aad50bc8478e11f660e2ce9efac614bb5c393f Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Mon, 16 Dec 2024 12:59:55 -0500 Subject: [PATCH 7/8] Unify sense refs and protection override --- .../MergeDupsStep/MergeDragDrop/DragSense.tsx | 33 ++--- .../MergeDupsStep/MergeDragDrop/DropWord.tsx | 9 +- .../MergeDragDrop/SidebarDragSense.tsx | 11 +- .../MergeDragDrop/SidebarDrop.tsx | 12 +- .../MergeDupsStep/MergeDragDrop/index.tsx | 125 +++++++++--------- .../MergeDupsStep/protectReasonUtils.ts | 10 +- .../MergeDuplicates/MergeDupsTreeTypes.ts | 12 +- .../MergeDuplicates/Redux/MergeDupsReducer.ts | 2 +- .../Redux/tests/MergeDupsReducer.test.tsx | 9 +- 9 files changed, 111 insertions(+), 112 deletions(-) diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx index 9dc16cc087..ad6b48dbf4 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx @@ -4,7 +4,10 @@ import { Draggable } from "react-beautiful-dnd"; import { trashId } from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop"; import SenseCardContent from "goals/MergeDuplicates/MergeDupsStep/SenseCardContent"; -import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { + MergeTreeReference, + MergeTreeSense, +} from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { setSidebar } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; @@ -12,11 +15,9 @@ import theme from "types/theme"; interface DragSenseProps { index: number; - wordId: string; - mergeSenseId: string; - mergeSenses: MergeTreeSense[]; isOnlySenseInProtectedWord: boolean; - isProtectedSense: boolean; + mergeSenses: MergeTreeSense[]; + senseRef: MergeTreeReference; } function arraysEqual(arr1: T[], arr2: T[]): boolean { @@ -48,17 +49,13 @@ export default function DragSense(props: DragSenseProps): ReactElement { (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar ); const isInSidebar = - sidebar.wordId === props.wordId && - sidebar.mergeSenseId === props.mergeSenseId && + sidebar.senseRef.wordId === props.senseRef.wordId && + sidebar.senseRef.mergeSenseId === props.senseRef.mergeSenseId && sidebar.mergeSenses.length > 1; const updateSidebar = useCallback(() => { dispatch( - setSidebar({ - mergeSenses: props.mergeSenses, - wordId: props.wordId, - mergeSenseId: props.mergeSenseId, - }) + setSidebar({ mergeSenses: props.mergeSenses, senseRef: props.senseRef }) ); }, [dispatch, props]); @@ -91,12 +88,8 @@ export default function DragSense(props: DragSenseProps): ReactElement { return ( @@ -112,13 +105,13 @@ export default function DragSense(props: DragSenseProps): ReactElement { minWidth: 150, maxWidth: 300, opacity: - !props.isProtectedSense && + !props.senseRef.isSenseProtected && (snapshot.draggingOver === trashId || snapshot.combineWith) ? 0.7 : 1, background: isInSidebar ? "lightblue" - : props.isProtectedSense + : props.senseRef.isSenseProtected ? "lightyellow" : snapshot.draggingOver === trashId ? "red" diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index f79b4c4a28..dc6f72c526 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -76,11 +76,14 @@ export default function DropWord(props: DropWordProps): ReactElement { ); })} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx index c92b9c003a..96fda64157 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx @@ -4,10 +4,7 @@ import { Draggable } from "react-beautiful-dnd"; import { trashId } from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop"; import SenseCardContent from "goals/MergeDuplicates/MergeDupsStep/SenseCardContent"; -import { - MergeTreeReference, - MergeTreeSense, -} from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import theme from "types/theme"; @@ -21,10 +18,8 @@ export default function SidebarDragSense( props: SidebarDragSenseProps ): ReactElement { const draggableId = useAppSelector((state: StoreState) => { - const { mergeSenseId, wordId } = state.mergeDuplicateGoal.tree.sidebar; - const order = props.index; - const ref: MergeTreeReference = { wordId, mergeSenseId, order }; - return JSON.stringify(ref); + const ref = state.mergeDuplicateGoal.tree.sidebar.senseRef; + return JSON.stringify({ ...ref, order: props.index }); }); const overrideProtection = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.overrideProtection diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx index aa11fc5853..69908d2177 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx @@ -1,10 +1,10 @@ import { ArrowForwardIos, HelpOutline } from "@mui/icons-material"; import { Grid, IconButton, Typography } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { Droppable } from "react-beautiful-dnd"; import SidebarDragSense from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense"; -import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { type MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { setSidebar } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; @@ -15,16 +15,14 @@ export default function SidebarDrop(): ReactElement { const sidebar = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar ); + const { mergeSenseId, wordId } = sidebar.senseRef; const vernacular = useAppSelector((state: StoreState) => { const tree = state.mergeDuplicateGoal.tree; - return tree.words[tree.sidebar.wordId]?.vern; + return tree.words[tree.sidebar.senseRef.wordId]?.vern; }); return ( - + {(providedDroppable): ReactElement => (
state.mergeDuplicateGoal.overrideProtection ); - const senses = useAppSelector( - (state: StoreState) => state.mergeDuplicateGoal.data.senses - ); const sidebarOpen = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar.mergeSenses.length > 1 @@ -45,69 +46,65 @@ export default function MergeDragDrop(): ReactElement { (state: StoreState) => state.mergeDuplicateGoal.tree.words ); - const [protectedDataText, setProtectedDataText] = useState(""); - const [protectedDest, setProtectedDest] = useState< - MergeTreeReference | undefined - >(); - const [protectedSrc, setProtectedSrc] = useState< - MergeTreeReference | undefined - >(); + interface ProtectedOverride { + combinePayload?: CombineSenseMergePayload; + deletePayload?: MergeTreeReference; + movePayload?: MoveSensePayload; + orderPayload?: OrderSensePayload; + protectReason: string; + } + const [override, setOverride] = useState(); const [srcToDelete, setSrcToDelete] = useState< MergeTreeReference | undefined >(); const { t } = useTranslation(); - function startOverrideProtectedData( - protectedData: string, - src: MergeTreeReference, - dest?: MergeTreeReference - ): void { - setProtectedDest(dest); - setProtectedSrc(src); - setProtectedDataText( - t("mergeDups.helpText.protectedOverrideWarning", { - val: protectedData, - }) - ); - } - function handleDrop(res: DropResult): void { const src: MergeTreeReference = JSON.parse(res.draggableId); const srcWordId = res.source.droppableId; const srcWord = words[srcWordId]; - const senseReasons = senses[src.mergeSenseId].sense.protectReasons ?? []; - let wordReasons: ProtectReason[] = []; - if (srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1) { + + // Generate text for protected data that will be lost if user overrides. + const isOnlySenseInProtectedWord = + srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1; + let protectReason = ""; + if (overrideProtection) { + const wordReasons = isOnlySenseInProtectedWord + ? (srcWord.protectReasons ?? []) + : undefined; + const senseReasons = src.protectReasons; + protectReason = t("mergeDups.helpText.protectedOverrideWarning", { + val: protectReasonsText(t, wordReasons, senseReasons, false), + }); + } + + if (isOnlySenseInProtectedWord && !overrideProtection) { // Case 0: The final sense of a protected word cannot be moved. - if (overrideProtection) { - wordReasons = srcWord.protectReasons ?? []; - } else { - return; - } + return; } - const reasonsText = protectReasonsText(t, wordReasons, senseReasons); if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. - if (src.isSenseProtected) { + if (src.isSenseProtected || isOnlySenseInProtectedWord) { // Case 1a: Cannot delete a protected sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData(reasonsText, src); + setOverride({ deletePayload: src, protectReason }); } return; } setSrcToDelete(src); } else if (res.combine) { - const combineRef: MergeTreeReference = JSON.parse( - res.combine.draggableId - ); + const combinePayload: CombineSenseMergePayload = { + dest: JSON.parse(res.combine.draggableId), + src, + }; // Case 2: the sense was dropped on another sense. - if (src.isSenseProtected) { + if (src.isSenseProtected || isOnlySenseInProtectedWord) { // Case 2a: Cannot merge a protected sense into another sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData(reasonsText, src, combineRef); + setOverride({ combinePayload, protectReason }); } else if (srcWordId !== res.combine.droppableId) { // Otherwise, if target sense is in different word, move instead of combine. dispatch( @@ -120,13 +117,14 @@ export default function MergeDragDrop(): ReactElement { } return; } - if (combineRef.order !== undefined) { + if (combinePayload.dest.order !== undefined) { // Case 2b: If the target is a sidebar sub-sense, it cannot receive a combine. return; } // TODO: handle override case when sense is last in protected word - dispatch(combineSense({ src, dest: combineRef })); + dispatch(combineSense(combinePayload)); } else if (res.destination) { + const destOrder = res.destination.index; const destWordId = res.destination.droppableId; // Case 3: The sense was dropped in a droppable. if (srcWordId !== destWordId) { @@ -136,21 +134,26 @@ export default function MergeDragDrop(): ReactElement { return; } // Move the sense to the dest MergeWord. - dispatch( - moveSense({ src, destWordId, destOrder: res.destination.index }) - ); + const movePayload: MoveSensePayload = { destOrder, destWordId, src }; + if (isOnlySenseInProtectedWord) { + setOverride({ movePayload, protectReason }); + return; + } + dispatch(moveSense(movePayload)); } else { // Case 3b: The source & dest droppables are the same, so we reorder, not move. - const destOrder = res.destination.index; + const orderPayload: OrderSensePayload = { destOrder, src }; if ( src.order === destOrder || (destOrder === 0 && src.order !== undefined && sidebarProtected) ) { // If the sense wasn't moved or was moved within the sidebar above a protected sense, do nothing. - // TODO: Handle override case when moving above protected sense in sidebar + if (overrideProtection) { + setOverride({ orderPayload, protectReason }); + } return; } - dispatch(orderSense({ src, destOrder })); + dispatch(orderSense(orderPayload)); } } } @@ -163,16 +166,16 @@ export default function MergeDragDrop(): ReactElement { } function onConfirmOverride(): void { - if (protectedSrc) { - if (protectedDest) { - dispatch(combineSense({ src: protectedSrc, dest: protectedDest })); - } else { - dispatch(deleteSense(protectedSrc)); - } - setProtectedSrc(undefined); + if (override?.combinePayload) { + dispatch(combineSense(override.combinePayload)); + } else if (override?.deletePayload) { + dispatch(deleteSense(override.deletePayload)); + } else if (override?.movePayload) { + dispatch(moveSense(override.movePayload)); + } else if (override?.orderPayload) { + dispatch(orderSense(override.orderPayload)); } - setProtectedDest(undefined); - setProtectedDataText(""); + setOverride(undefined); } function renderSidebar(): ReactElement { @@ -235,9 +238,9 @@ export default function MergeDragDrop(): ReactElement { {renderSidebar()} setProtectedDataText("")} + open={!!override} + text={override?.protectReason ?? ""} + handleCancel={() => setOverride(undefined)} handleConfirm={onConfirmOverride} /> , - wordReasons: ProtectReason[], - senseReasons: ProtectReason[] + wordReasons: ProtectReason[] = [], + senseReasons: ProtectReason[] = [], + defaultPreface = true ): string { const wordTexts = wordReasons.map((r) => wordReasonText(t, r)); const senseTexts = senseReasons.map((r) => senseReasonText(t, r)); - return t("mergeDups.helpText.protectedData", { - val: [...wordTexts, ...senseTexts].join(sep), - }); + const val = [...wordTexts, ...senseTexts].join(sep); + return defaultPreface ? t("mergeDups.helpText.protectedData", { val }) : val; } /** Cases match Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) */ diff --git a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts index 38b06022ae..85b544386b 100644 --- a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts @@ -14,6 +14,7 @@ import { newFlag, newNote, newSense } from "types/word"; export interface MergeTreeSense { order: number; protected: boolean; + protectReasons?: ProtectReason[]; srcWordId: string; sense: Sense; } @@ -30,6 +31,7 @@ export interface MergeTreeReference { mergeSenseId: string; order?: number; isSenseProtected?: boolean; + protectReasons?: ProtectReason[]; } export interface MergeTreeWord { @@ -78,6 +80,7 @@ export function convertSenseToMergeTreeSense( return { order, protected: sense?.accessibility === Status.Protected, + protectReasons: sense?.protectReasons ?? undefined, srcWordId, sense, }; @@ -98,14 +101,15 @@ export function convertWordToMergeTreeWord(word: Word): MergeTreeWord { export interface Sidebar { mergeSenses: MergeTreeSense[]; - wordId: string; - mergeSenseId: string; + senseRef: MergeTreeReference; } export const defaultSidebar: Sidebar = { mergeSenses: [], - wordId: "", - mergeSenseId: "", + senseRef: { + wordId: "", + mergeSenseId: "", + }, }; export interface MergeTree { diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index 48a4ac439c..30ae33bc16 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -91,7 +91,7 @@ const mergeDuplicatesSlice = createSlice({ } // If the deleted sense was open in the sidebar, reset the sidebar. - const { mergeSenseId, wordId } = state.tree.sidebar; + const { mergeSenseId, wordId } = state.tree.sidebar.senseRef; if (mergeSenseId === srcRef.mergeSenseId && wordId === srcRef.wordId) { state.tree.sidebar = defaultSidebar; } diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx index d0059017cc..d707da1500 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx @@ -102,8 +102,11 @@ describe("MergeDupsReducer", () => { ...defaultTree, sidebar: { ...defaultSidebar, - mergeSenseId: "word2_senseA", - wordId: "word2", + senseRef: { + ...defaultSidebar.senseRef, + mergeSenseId: "word2_senseA", + wordId: "word2", + }, }, words: testTreeWords(), }, @@ -116,7 +119,7 @@ describe("MergeDupsReducer", () => { sidebarClosed = false ): void { const { sidebar, words } = mergeDupStepReducer(mockState, action).tree; - expect(!sidebar.wordId).toEqual(sidebarClosed); + expect(!sidebar.senseRef.wordId).toEqual(sidebarClosed); // Stringify for this test, because order within `.sensesGuids` matters. expect(JSON.stringify(words)).toEqual(JSON.stringify(expectedWords)); } From d232d4b6e191e9e1a7ee1f8990cde12f04aece36 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Mon, 16 Dec 2024 13:15:29 -0500 Subject: [PATCH 8/8] Fix test --- .../MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx | 3 +-- .../MergeDupsStep/MergeDragDrop/tests/index.test.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx index 01ff36add3..a673035b1d 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx @@ -82,8 +82,7 @@ export default function MergeDragDrop(): ReactElement { if (isOnlySenseInProtectedWord && !overrideProtection) { // Case 0: The final sense of a protected word cannot be moved. return; - } - if (res.destination?.droppableId === trashId) { + } else if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. if (src.isSenseProtected || isOnlySenseInProtectedWord) { // Case 1a: Cannot delete a protected sense. diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/tests/index.test.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/tests/index.test.tsx index dc72809bb9..50ffb10264 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/tests/index.test.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/tests/index.test.tsx @@ -156,6 +156,6 @@ describe("MergeDragDrop", () => { }); expect(mockSetSidebar).toHaveBeenCalledTimes(1); const callArg = mockSetSidebar.mock.calls[0][0]; - expect(callArg.mergeSenseId).toEqual("word1_senseA"); + expect(callArg.senseRef.mergeSenseId).toEqual("word1_senseA"); }); });