From 6411f29b16c13cc0e1ee2c77d194d0da8d2682f4 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Thu, 23 Jul 2026 17:09:48 -0400 Subject: [PATCH 1/9] claude create categories on submission --- .../components/Discussions/DiscussionCard.tsx | 11 ++- .../PassageDetailArtifacts.tsx | 17 +++++ .../PassageDetailsArtifactsMobile.tsx | 17 +++++ .../Internalization/ResourceData.tsx | 7 +- .../ResourceEdit/ResourceCategory.tsx | 11 ++- .../ResourceEdit/ResourceOverview.tsx | 20 ++++-- .../Sheet/SelectArtifactCategory.tsx | 70 +++++++++++++++---- src/renderer/src/components/Uploader.tsx | 9 ++- 8 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/Discussions/DiscussionCard.tsx b/src/renderer/src/components/Discussions/DiscussionCard.tsx index b582b535..a0e83de6 100644 --- a/src/renderer/src/components/Discussions/DiscussionCard.tsx +++ b/src/renderer/src/components/Discussions/DiscussionCard.tsx @@ -250,6 +250,9 @@ export const DiscussionCard = (props: IProps) => { const [myChanged, setMyChanged] = useState(false); const segSavingRef = useRef(false); const cardSavingRef = useRef(false); + // commit() handle for the category field; called at save so a newly typed + // category is created on submission rather than on blur. + const catCommitRef = useRef<(() => Promise) | null>(null); const [showMove, setShowMove] = useState(false); const [moveTo, setMoveTo] = useState(); const waitForRemoteQueue = useWaitForRemoteQueue(); @@ -729,6 +732,11 @@ export const DiscussionCard = (props: IProps) => { //we should only get here with no subject if they've clicked off the screen and then told us to save with no subject discussion.attributes.subject = editSubject.length > 0 ? editSubject : tdcs.topic; + // Create the category now (at save) if the user typed a new one; on blur it + // was only resolved against existing categories. + const catId = catCommitRef.current + ? await catCommitRef.current() + : editCategory; const ops: RecordOperation[] = []; const t = new RecordTransformBuilder(); if (!discussion.id) { @@ -770,7 +778,7 @@ export const DiscussionCard = (props: IProps) => { discussion, 'artifactCategory', 'artifactcategory', - editCategory, + catId, user ), ...UpdateRelatedRecord( @@ -988,6 +996,7 @@ export const DiscussionCard = (props: IProps) => { id={`category-${discussion.id}`} initCategory={editCategory} onCategoryChange={onCategoryChange} + commitRef={catCommitRef} allowNew={userIsAdmin && (!offline || offlineOnly)} required={false} scripture={ArtCatScr.hide} diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx index 015be472..887f73ae 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx @@ -182,6 +182,11 @@ export function PassageDetailArtifacts() { const mediaRef = useRef(undefined); const textRef = useRef(undefined); const catIdRef = useRef(undefined); + // commit() handles for the two SelectArtifactCategory instances (edit dialog + // vs. the add/upload dialog). Kept separate so one dialog unmounting never + // clears the other's handle. Called at save to create a new category. + const editCatCommitRef = useRef<(() => Promise) | null>(null); + const addCatCommitRef = useRef<(() => Promise) | null>(null); const descriptionRef = useRef(''); const resourceTypeRef = useRef( @@ -429,6 +434,12 @@ export function PassageDetailArtifacts() { if (!v) resetEdit(); }; const handleEditSave = async () => { + // Create the category now (at save) if the user typed a new one; on blur it + // was only resolved against existing categories. + if (editCatCommitRef.current) { + const catId = await editCatCommitRef.current(); + catIdRef.current = catId; + } if (editResource) { UpdateSectionResource({ ...editResource, @@ -945,6 +956,10 @@ export function PassageDetailArtifacts() { showMessage={showMessage} multiple={true} finish={afterUpload} + beforeUpload={async () => { + if (addCatCommitRef.current) + catIdRef.current = await addCatCommitRef.current(); + }} cancelled={cancelled} cancelReset={resetEdit} artifactState={artifactState} @@ -962,6 +977,7 @@ export function PassageDetailArtifacts() { catAllowNew={true} //if they can upload they can add cat initCategory="" onCategoryChange={handleCategory} + catCommitRef={addCatCommitRef} initDescription={initDescription} onDescriptionChange={handleDescription} catRequired={false} @@ -1072,6 +1088,7 @@ export function PassageDetailArtifacts() { catAllowNew={true} initCategory={catIdRef.current || ''} onCategoryChange={handleCategory} + catCommitRef={editCatCommitRef} initDescription={descriptionRef.current} onDescriptionChange={handleDescription} catRequired={false} diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx index 31ec5d29..4731f749 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx @@ -172,6 +172,11 @@ export function PassageDetailArtifactsMobile() { const mediaRef = useRef(undefined); const textRef = useRef(undefined); const catIdRef = useRef(undefined); + // Separate commit() handles for the edit dialog vs. the add/upload dialog so + // one unmounting never clears the other's. Called at save to create a new + // category the user typed (deferred from blur). + const editCatCommitRef = useRef<(() => Promise) | null>(null); + const addCatCommitRef = useRef<(() => Promise) | null>(null); const descriptionRef = useRef(''); const resourceTypeRef = useRef( @@ -426,6 +431,12 @@ export function PassageDetailArtifactsMobile() { if (!v) resetEdit(); }; const handleEditSave = async () => { + // Create the category now (at save) if the user typed a new one; on blur it + // was only resolved against existing categories. + if (editCatCommitRef.current) { + const catId = await editCatCommitRef.current(); + catIdRef.current = catId; + } if (editResource) { UpdateSectionResource({ ...editResource, @@ -938,6 +949,10 @@ export function PassageDetailArtifactsMobile() { showMessage={showMessage} multiple={true} finish={afterUpload} + beforeUpload={async () => { + if (addCatCommitRef.current) + catIdRef.current = await addCatCommitRef.current(); + }} cancelled={cancelled} cancelReset={resetEdit} artifactState={artifactState} @@ -955,6 +970,7 @@ export function PassageDetailArtifactsMobile() { catAllowNew={true} //if they can upload they can add cat initCategory="" onCategoryChange={handleCategory} + catCommitRef={addCatCommitRef} initDescription={initDescription} onDescriptionChange={handleDescription} catRequired={false} @@ -1080,6 +1096,7 @@ export function PassageDetailArtifactsMobile() { catAllowNew={true} initCategory={catIdRef.current || ''} onCategoryChange={handleCategory} + catCommitRef={editCatCommitRef} initDescription={descriptionRef.current} onDescriptionChange={handleDescription} catRequired={false} diff --git a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx index 365a082c..8bf6a78e 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx @@ -9,7 +9,7 @@ import { TextField, Typography, } from '@mui/material'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, type MutableRefObject } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; import { ArtifactCategoryType, useOrganizedBy } from '../../../crud'; import { @@ -45,6 +45,9 @@ interface IProps { allowProject: boolean; catRequired: boolean; catAllowNew?: boolean | undefined; + // Forwarded to SelectArtifactCategory so the parent form can create a new + // category at submission (rather than on blur). + catCommitRef?: MutableRefObject<(() => Promise) | null> | undefined; sectDesc?: string | undefined; passDesc?: string | undefined; wrapPreviewOverflow?: boolean; @@ -66,6 +69,7 @@ export function ResourceData(props: IProps) { uploadType, onTextChange, wrapPreviewOverflow, + catCommitRef, } = props; const [description, setDescription] = useState(initDescription); const { getOrganizedBy } = useOrganizedBy(); @@ -149,6 +153,7 @@ export function ResourceData(props: IProps) { required={catRequired} scripture={ArtCatScr.highlight} type={ArtifactCategoryType.Resource} + commitRef={catCommitRef} /> diff --git a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx index e4d6d084..09cae65e 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx @@ -1,9 +1,15 @@ +import type { MutableRefObject } from 'react'; import { IResourceState } from '.'; import SelectArtifactCategory from '../Sheet/SelectArtifactCategory'; import { ArtifactCategoryType } from '../../crud'; -export const ResourceCategory = (props: IResourceState) => { - const { state, setState } = props; +interface IProps extends IResourceState { + // Forwarded so the wizard can create a new category at save (not on blur). + commitRef?: MutableRefObject<(() => Promise) | null> | undefined; +} + +export const ResourceCategory = (props: IProps) => { + const { state, setState, commitRef } = props; const { category, note } = state; const handleChange = (category: string) => { @@ -18,6 +24,7 @@ export const ResourceCategory = (props: IResourceState) => { onCategoryChange={setState ? handleChange : undefined} required={false} allowNew + commitRef={commitRef} /> ); }; diff --git a/src/renderer/src/components/ResourceEdit/ResourceOverview.tsx b/src/renderer/src/components/ResourceEdit/ResourceOverview.tsx index b36b4845..3337e931 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceOverview.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceOverview.tsx @@ -90,6 +90,9 @@ export default function ResourceOverview(props: IProps) { const [isDeveloper] = useGlobal('developer'); const recording = useRef(false); + // commit() handle for the category field; called at save so a newly typed + // category is created on submission rather than on blur. + const catCommitRef = useRef<(() => Promise) | null>(null); const { getOrgDefault, setOrgDefault, canSetOrgDefault } = useOrgDefaults(); const [findNote, setFindNote] = React.useState(false); const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); @@ -161,8 +164,13 @@ export default function ResourceOverview(props: IProps) { if (onCancel) onCancel(); }; - const handleAdd = () => { - onCommit(state); + const handleAdd = async () => { + // Create the category now (at save) if the user typed a new one; on blur it + // was only resolved against existing categories. + const category = catCommitRef.current + ? await catCommitRef.current() + : state.category; + onCommit({ ...state, category }); }; const handleLanguageChange = (val: ILanguage) => { @@ -209,7 +217,11 @@ export default function ResourceOverview(props: IProps) { )} - + {!isNote ? ( <> @@ -261,7 +273,7 @@ export default function ResourceOverview(props: IProps) { {dialogmode !== Mode.view && ( handleAdd()} disabled={ title === '' || (bcp47 === 'und' && !isNote) || diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index c4a4e746..ff8f6802 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -6,7 +6,7 @@ import { SxProps, TextField, } from '@mui/material'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type MutableRefObject } from 'react'; import { ArtifactCategoryType, IArtifactCategory, @@ -30,6 +30,11 @@ interface IProps { scripture?: ArtCatScr | undefined; type: ArtifactCategoryType; disabled?: boolean | undefined; + // When provided, the parent gets a `commit()` here to run at form submission: + // it resolves the current input to a category id (creating a new category + // when the typed name matches none) and returns it. New categories are then + // created on commit rather than on blur. + commitRef?: MutableRefObject<(() => Promise) | null> | undefined; } const StyledBox = styled(Box)(() => ({ @@ -57,6 +62,7 @@ export const SelectArtifactCategory = (props: IProps) => { scripture, type, disabled, + commitRef, } = props; const artifactCategories = useOrbitData('artifactcategory'); @@ -109,9 +115,11 @@ export const SelectArtifactCategory = (props: IProps) => { onCategoryChange && onCategoryChange(id); }; - // Resolve a chosen/typed category name to an id, creating a new category - // when the name doesn't match an existing one. - const resolveCommit = async (raw: string | null) => { + // Resolve a chosen/typed category name to an existing category id. Called on + // blur and on change. Deliberately does NOT create new categories — that is + // deferred to commit() (form submission) so navigating away without saving + // never leaves an orphaned category behind. + const resolveExisting = (raw: string | null) => { const name = (raw ?? '').trim(); if (name.toLowerCase() === currentName.trim().toLowerCase()) return; if (!name) { @@ -132,7 +140,30 @@ export const SelectArtifactCategory = (props: IProps) => { setInputVal(currentName); return; } - if (committingRef.current) return; + // allowNew + a new name: keep the typed text as-is and wait for commit(). + }; + + // Resolve the current input to a category id, creating a new category when + // the typed name matches none. Intended to run at form submission via + // commitRef. Returns the resolved id (empty string when the field is blank). + const commit = async (): Promise => { + const name = inputVal.trim(); + if (!name) { + setCategory(''); + return ''; + } + const existing = artifactCategorys.find( + (c) => c.category.trim().toLowerCase() === name.toLowerCase() + ); + if (existing) { + setCategory(existing.id); + return existing.id; + } + if (!allowNew) { + setInputVal(currentName); + return categoryId; + } + if (committingRef.current) return categoryId; committingRef.current = true; try { const newId = await addNewArtifactCategory(name, type); @@ -140,17 +171,32 @@ export const SelectArtifactCategory = (props: IProps) => { setArtifactCategorys(cats); if (newId && newId !== 'duplicate') { setCategory(newId); - } else { - const dup = cats.find( - (c) => c.category.trim().toLowerCase() === name.toLowerCase() - ); - if (dup) setCategory(dup.id); + return newId; + } + const dup = cats.find( + (c) => c.category.trim().toLowerCase() === name.toLowerCase() + ); + if (dup) { + setCategory(dup.id); + return dup.id; } + return categoryId; } finally { committingRef.current = false; } }; + // Expose commit() to the parent form. No dependency array: re-assign every + // render so the closure stays current; clear on unmount so a stale commit is + // never invoked once the field is gone. + useEffect(() => { + if (!commitRef) return; + commitRef.current = commit; + return () => { + commitRef.current = null; + }; + }); + return ( { value={currentName || null} inputValue={inputVal} onInputChange={(_e, v) => setInputVal(v)} - onChange={(_e, v) => resolveCommit(v)} - onBlur={() => resolveCommit(inputVal)} + onChange={(_e, v) => resolveExisting(v)} + onBlur={() => resolveExisting(inputVal)} sx={textFieldProps} renderOption={(liProps, option, { index }) => { const cat = artifactCategorys.find((c) => c.category === option); diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 0a618214..71af3804 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -54,6 +54,10 @@ interface IProps { | ((planId: string, mediaRemoteIds?: string[]) => Promise) | undefined; // logic when upload complete metaData?: React.JSX.Element | undefined; // component embeded in dialog + // Awaited once when the upload starts, before any media is created. Lets the + // embedded metaData commit deferred edits (e.g. create a new artifact + // category) so `finish` sees the resolved ids. + beforeUpload?: (() => Promise) | undefined; ready?: (() => boolean) | undefined; // if false control is disabled // createProject?: (name: string) => Promise; cancelled: React.RefObject; @@ -107,7 +111,7 @@ export const Uploader = (props: IProps) => { finish, uploadDialogBp, } = props; - const { metaData, ready } = props; + const { metaData, ready, beforeUpload } = props; const [isDeveloper] = useGlobal('developer'); const t: IMediaTabStrings = useSelector(mediaTabSelector, shallowEqual); const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); @@ -393,6 +397,9 @@ export const Uploader = (props: IProps) => { showMessage(t.selectFiles); return; } + // Commit any deferred metaData edits (e.g. create a newly typed artifact + // category) before media records are created so `finish` sees final ids. + if (beforeUpload) await beforeUpload(); if ( uploadType && ![ From fb97afbcda53d2f5259490ecd8bc53b51b65c1a0 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Thu, 23 Jul 2026 17:50:44 -0400 Subject: [PATCH 2/9] refactor: use RefObject instead of deprecated MutableRefObject React 19 types collapse the two ref shapes into a single writable RefObject, deprecating MutableRefObject. Swap the category commit-ref prop types accordingly; useRef call sites are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/PassageDetail/Internalization/ResourceData.tsx | 4 ++-- src/renderer/src/components/ResourceEdit/ResourceCategory.tsx | 4 ++-- src/renderer/src/components/Sheet/SelectArtifactCategory.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx index 8bf6a78e..0833cf71 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx @@ -9,7 +9,7 @@ import { TextField, Typography, } from '@mui/material'; -import React, { useEffect, useState, type MutableRefObject } from 'react'; +import React, { useEffect, useState, type RefObject } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; import { ArtifactCategoryType, useOrganizedBy } from '../../../crud'; import { @@ -47,7 +47,7 @@ interface IProps { catAllowNew?: boolean | undefined; // Forwarded to SelectArtifactCategory so the parent form can create a new // category at submission (rather than on blur). - catCommitRef?: MutableRefObject<(() => Promise) | null> | undefined; + catCommitRef?: RefObject<(() => Promise) | null> | undefined; sectDesc?: string | undefined; passDesc?: string | undefined; wrapPreviewOverflow?: boolean; diff --git a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx index 09cae65e..128c9224 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx @@ -1,11 +1,11 @@ -import type { MutableRefObject } from 'react'; +import type { RefObject } from 'react'; import { IResourceState } from '.'; import SelectArtifactCategory from '../Sheet/SelectArtifactCategory'; import { ArtifactCategoryType } from '../../crud'; interface IProps extends IResourceState { // Forwarded so the wizard can create a new category at save (not on blur). - commitRef?: MutableRefObject<(() => Promise) | null> | undefined; + commitRef?: RefObject<(() => Promise) | null> | undefined; } export const ResourceCategory = (props: IProps) => { diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index ff8f6802..0698b19d 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -6,7 +6,7 @@ import { SxProps, TextField, } from '@mui/material'; -import { useEffect, useRef, useState, type MutableRefObject } from 'react'; +import { useEffect, useRef, useState, type RefObject } from 'react'; import { ArtifactCategoryType, IArtifactCategory, @@ -34,7 +34,7 @@ interface IProps { // it resolves the current input to a category id (creating a new category // when the typed name matches none) and returns it. New categories are then // created on commit rather than on blur. - commitRef?: MutableRefObject<(() => Promise) | null> | undefined; + commitRef?: RefObject<(() => Promise) | null> | undefined; } const StyledBox = styled(Box)(() => ({ From d54c9a674cfd1431e766f1e7a83a7c2d3f4bc541 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Thu, 23 Jul 2026 18:17:42 -0400 Subject: [PATCH 3/9] fix: create typed artifact category when recording an audio resource The recording path reaches finish() via afterUploadCb rather than uploadMedia, so the beforeUpload commit never ran and a newly typed category was silently dropped (resource saved with no category, category never created). Commit deferred metaData edits in afterUploadCb too, guarded on mediaId so no orphan category is created when nothing was recorded. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/src/components/Uploader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 71af3804..13573822 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -160,6 +160,7 @@ export const Uploader = (props: IProps) => { const afterUploadCb = async (mediaId: string | undefined) => { if (mediaId) { + if (beforeUpload) await beforeUpload(); successCount.current = 1; mediaIdRef.current = [mediaId]; } else successCount.current = 0; From 0c09d2bce4c0393a22327fa811bd6902c02941dc Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Fri, 24 Jul 2026 10:33:22 -0400 Subject: [PATCH 4/9] fix: enable Save for a new-category-only edit (Devin #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring new-category creation to commit() means a brand-new typed name never fires onCategoryChange, so consumers that gate their Save button on a `changed` flag (ResourceOverview, DiscussionCard) could never enable it when the new category was the only edit — a regression vs the old create-on-blur behavior. Add a lightweight onNewDraft(hasDraft) callback to SelectArtifactCategory that fires as the user types a new (allowNew) name matching no existing category. ResourceCategory and DiscussionCard use it to mark themselves dirty. Kept the single commitRef function ref as the creation channel (no second ref / draft-name machinery); a ref alone can't reactively re-enable a button, so one small callback is the minimal complement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/Discussions/DiscussionCard.tsx | 7 ++++++ .../ResourceEdit/ResourceCategory.tsx | 9 ++++++++ .../Sheet/SelectArtifactCategory.tsx | 23 ++++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/Discussions/DiscussionCard.tsx b/src/renderer/src/components/Discussions/DiscussionCard.tsx index a0e83de6..c123df07 100644 --- a/src/renderer/src/components/Discussions/DiscussionCard.tsx +++ b/src/renderer/src/components/Discussions/DiscussionCard.tsx @@ -728,6 +728,12 @@ export const DiscussionCard = (props: IProps) => { setChanged(true); } }; + // A brand-new typed category is only committed to an id at save, so it never + // fires onCategoryChange; mark the discussion changed so Save can enable when + // a new category is the only edit. + const onCategoryNewDraft = (hasDraft: boolean) => { + if (hasDraft) setChanged(true); + }; const saveDiscussion = async () => { //we should only get here with no subject if they've clicked off the screen and then told us to save with no subject discussion.attributes.subject = @@ -996,6 +1002,7 @@ export const DiscussionCard = (props: IProps) => { id={`category-${discussion.id}`} initCategory={editCategory} onCategoryChange={onCategoryChange} + onNewDraft={onCategoryNewDraft} commitRef={catCommitRef} allowNew={userIsAdmin && (!offline || offlineOnly)} required={false} diff --git a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx index 128c9224..11ff9624 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx @@ -16,12 +16,21 @@ export const ResourceCategory = (props: IProps) => { setState && setState((state) => ({ ...state, category, changed: true })); }; + // A brand-new typed category isn't committed to an id until save, so it never + // fires onCategoryChange; mark the form dirty here so Save can enable when a + // new category is the only edit. + const handleNewDraft = (hasDraft: boolean) => { + if (hasDraft && setState) + setState((state) => (state.changed ? state : { ...state, changed: true })); + }; + return ( Promise) | null> | undefined; + // Fires as the user types when the field holds a brand-new (allowNew) name + // that matches no existing category. Because a new name isn't committed to a + // category id until commit() runs at save, parents that gate their Save + // button on a "changed" flag would otherwise never enable it for a + // new-category-only edit; they use this to mark themselves dirty. + onNewDraft?: ((hasDraft: boolean) => void) | undefined; } const StyledBox = styled(Box)(() => ({ @@ -63,6 +69,7 @@ export const SelectArtifactCategory = (props: IProps) => { type, disabled, commitRef, + onNewDraft, } = props; const artifactCategories = useOrbitData('artifactcategory'); @@ -209,7 +216,21 @@ export const SelectArtifactCategory = (props: IProps) => { .sort((a, b) => (a < b ? -1 : 1))} value={currentName || null} inputValue={inputVal} - onInputChange={(_e, v) => setInputVal(v)} + onInputChange={(_e, v, reason) => { + setInputVal(v); + // Only react to real keystrokes, not the programmatic 'reset' MUI + // fires when the committed value changes. + if (reason !== 'input' || !onNewDraft) return; + const name = v.trim(); + const hasDraft = + !!allowNew && + name !== '' && + name.toLowerCase() !== currentName.trim().toLowerCase() && + !artifactCategorys.some( + (c) => c.category.trim().toLowerCase() === name.toLowerCase() + ); + onNewDraft(hasDraft); + }} onChange={(_e, v) => resolveExisting(v)} onBlur={() => resolveExisting(inputVal)} sx={textFieldProps} From abcb417c70e4d5ca727f0281e928672e0669bfa4 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Fri, 24 Jul 2026 11:07:57 -0400 Subject: [PATCH 5/9] fix: never show a duplicated category name in the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Autocomplete options were the raw localized names with no dedupe, and a render-index was baked into the React key to avoid collisions when two categories shared a localized name — i.e. it masked the duplicate rather than preventing it. New-category creation already blocks duplicate localized names, but two distinct slugs can still collapse to the same localized string (e.g. created under different languages). Deduplicate the options by localized name (keeping the first occurrence, matching how resolveExisting/commit resolve a typed name to an id), so a name can never appear twice. The key can then be the unique category id and the index workaround is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sheet/SelectArtifactCategory.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index 8d25c3f1..1bfbe636 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -204,16 +204,34 @@ export const SelectArtifactCategory = (props: IProps) => { }; }); + // Options are the localized category names, deduplicated so a name can never + // appear twice. New-category creation already blocks duplicate localized + // names (useArtifactCategory.isDuplicateCategory), but two distinct category + // slugs can still collapse to the same localized string (e.g. created under + // different languages), so we enforce uniqueness here at the point of use. + // Keeping the first occurrence matches how resolveExisting/commit resolve a + // typed name to an id (first case-insensitive match). + const categoryOptions = (() => { + const seen = new Set(); + const names: string[] = []; + artifactCategorys.forEach((c) => { + const name = c.category; + if (!name) return; + const key = name.trim().toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + names.push(name); + }); + return names.sort((a, b) => (a < b ? -1 : 1)); + })(); + return ( c.category) - .filter(Boolean) - .sort((a, b) => (a < b ? -1 : 1))} + options={categoryOptions} value={currentName || null} inputValue={inputVal} onInputChange={(_e, v, reason) => { @@ -234,16 +252,16 @@ export const SelectArtifactCategory = (props: IProps) => { onChange={(_e, v) => resolveExisting(v)} onBlur={() => resolveExisting(inputVal)} sx={textFieldProps} - renderOption={(liProps, option, { index }) => { + renderOption={(liProps, option) => { const cat = artifactCategorys.find((c) => c.category === option); const isScr = scripture === ArtCatScr.highlight && cat && scriptureTypeCategory(cat.slug); return ( - // Include the render index so two categories that share a localized - // name can't collide on the same React key. -
  • + // categoryOptions is deduplicated by name, so the category id (or + // the option string as a fallback) is already a unique React key. +
  • {option} {isScr && ( From fe48ffdcbfe0513f1d82844db07c10253051f171 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Fri, 24 Jul 2026 12:04:37 -0400 Subject: [PATCH 6/9] fix: don't re-resolve an unchanged category on save (Devin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit() ran on every save and re-resolved the field text to an id by name. When the field is unchanged that round-trip could (a) drop the category — if its localized name is filtered out of the options the field shows blank, so commit() saw '' and cleared it — or (b) switch it to a different id when two slugs share the same localized name (commit picks the first match, not necessarily the committed one). Short-circuit commit() when the trimmed input equals the committed category's name (including empty === empty), returning the existing categoryId untouched. New/changed names still resolve and create as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/Sheet/SelectArtifactCategory.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index 1bfbe636..951fac22 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -155,6 +155,15 @@ export const SelectArtifactCategory = (props: IProps) => { // commitRef. Returns the resolved id (empty string when the field is blank). const commit = async (): Promise => { const name = inputVal.trim(); + // Field unchanged from the committed category — keep its id as-is rather + // than re-resolving by name. Re-resolving could silently drop the category + // (when its localized name is filtered out of the options and the field + // therefore shows blank) or switch it to a different id (when two slugs + // share the same localized name). The empty === empty case also preserves + // such a filtered-out category on an untouched save. + if (name.toLowerCase() === currentName.trim().toLowerCase()) { + return categoryId; + } if (!name) { setCategory(''); return ''; From 2b80782f590fa7abbbbc49c54071e3446cf4df42 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Fri, 24 Jul 2026 12:57:26 -0400 Subject: [PATCH 7/9] Revert "fix: never show a duplicated category name in the picker" This reverts commit 6ce886769242e6cddd13fe65f0cb0bb3361fe722. --- .../Sheet/SelectArtifactCategory.tsx | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index 951fac22..16fc8ab2 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -213,34 +213,16 @@ export const SelectArtifactCategory = (props: IProps) => { }; }); - // Options are the localized category names, deduplicated so a name can never - // appear twice. New-category creation already blocks duplicate localized - // names (useArtifactCategory.isDuplicateCategory), but two distinct category - // slugs can still collapse to the same localized string (e.g. created under - // different languages), so we enforce uniqueness here at the point of use. - // Keeping the first occurrence matches how resolveExisting/commit resolve a - // typed name to an id (first case-insensitive match). - const categoryOptions = (() => { - const seen = new Set(); - const names: string[] = []; - artifactCategorys.forEach((c) => { - const name = c.category; - if (!name) return; - const key = name.trim().toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - names.push(name); - }); - return names.sort((a, b) => (a < b ? -1 : 1)); - })(); - return ( c.category) + .filter(Boolean) + .sort((a, b) => (a < b ? -1 : 1))} value={currentName || null} inputValue={inputVal} onInputChange={(_e, v, reason) => { @@ -261,16 +243,16 @@ export const SelectArtifactCategory = (props: IProps) => { onChange={(_e, v) => resolveExisting(v)} onBlur={() => resolveExisting(inputVal)} sx={textFieldProps} - renderOption={(liProps, option) => { + renderOption={(liProps, option, { index }) => { const cat = artifactCategorys.find((c) => c.category === option); const isScr = scripture === ArtCatScr.highlight && cat && scriptureTypeCategory(cat.slug); return ( - // categoryOptions is deduplicated by name, so the category id (or - // the option string as a fallback) is already a unique React key. -
  • + // Include the render index so two categories that share a localized + // name can't collide on the same React key. +
  • {option} {isScr && ( From c15f3d96deee415d9b6ec03c33d0a9ea5591931b Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Tue, 28 Jul 2026 13:13:15 -0400 Subject: [PATCH 8/9] comment --- src/renderer/src/components/Sheet/SelectArtifactCategory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index 16fc8ab2..e763d80b 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -250,8 +250,8 @@ export const SelectArtifactCategory = (props: IProps) => { cat && scriptureTypeCategory(cat.slug); return ( - // Include the render index so two categories that share a localized - // name can't collide on the same React key. + // We already prevent duplicate categories, but include the render index just a fallback in case somehow two + // categories end up with the same localized name in this particular language (shouldn't happen?)
  • {option} {isScr && ( From 1ded5119fef9233362d959b4560f0bcd5c4a3425 Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Tue, 28 Jul 2026 13:30:50 -0400 Subject: [PATCH 9/9] simplify onNewDraft --- .../components/Discussions/DiscussionCard.tsx | 4 +-- .../ResourceEdit/ResourceCategory.tsx | 4 +-- .../Sheet/SelectArtifactCategory.tsx | 27 +++++++------------ 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/Discussions/DiscussionCard.tsx b/src/renderer/src/components/Discussions/DiscussionCard.tsx index c123df07..84a906e0 100644 --- a/src/renderer/src/components/Discussions/DiscussionCard.tsx +++ b/src/renderer/src/components/Discussions/DiscussionCard.tsx @@ -731,9 +731,7 @@ export const DiscussionCard = (props: IProps) => { // A brand-new typed category is only committed to an id at save, so it never // fires onCategoryChange; mark the discussion changed so Save can enable when // a new category is the only edit. - const onCategoryNewDraft = (hasDraft: boolean) => { - if (hasDraft) setChanged(true); - }; + const onCategoryNewDraft = () => setChanged(true); const saveDiscussion = async () => { //we should only get here with no subject if they've clicked off the screen and then told us to save with no subject discussion.attributes.subject = diff --git a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx index 11ff9624..efd70e5b 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx @@ -19,8 +19,8 @@ export const ResourceCategory = (props: IProps) => { // A brand-new typed category isn't committed to an id until save, so it never // fires onCategoryChange; mark the form dirty here so Save can enable when a // new category is the only edit. - const handleNewDraft = (hasDraft: boolean) => { - if (hasDraft && setState) + const handleNewDraft = () => { + setState && setState((state) => (state.changed ? state : { ...state, changed: true })); }; diff --git a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx index e763d80b..82ba02f5 100644 --- a/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx +++ b/src/renderer/src/components/Sheet/SelectArtifactCategory.tsx @@ -35,12 +35,11 @@ interface IProps { // when the typed name matches none) and returns it. New categories are then // created on commit rather than on blur. commitRef?: RefObject<(() => Promise) | null> | undefined; - // Fires as the user types when the field holds a brand-new (allowNew) name - // that matches no existing category. Because a new name isn't committed to a - // category id until commit() runs at save, parents that gate their Save - // button on a "changed" flag would otherwise never enable it for a + // Fires as the user types in an allowNew field, i.e. whenever the field holds + // text that only commit() will resolve to a category id. Parents that gate + // their Save button on a "changed" flag would otherwise never enable it for a // new-category-only edit; they use this to mark themselves dirty. - onNewDraft?: ((hasDraft: boolean) => void) | undefined; + onNewDraft?: (() => void) | undefined; } const StyledBox = styled(Box)(() => ({ @@ -227,18 +226,12 @@ export const SelectArtifactCategory = (props: IProps) => { inputValue={inputVal} onInputChange={(_e, v, reason) => { setInputVal(v); - // Only react to real keystrokes, not the programmatic 'reset' MUI - // fires when the committed value changes. - if (reason !== 'input' || !onNewDraft) return; - const name = v.trim(); - const hasDraft = - !!allowNew && - name !== '' && - name.toLowerCase() !== currentName.trim().toLowerCase() && - !artifactCategorys.some( - (c) => c.category.trim().toLowerCase() === name.toLowerCase() - ); - onNewDraft(hasDraft); + // A typed name isn't resolved to an id until commit(), so + // onCategoryChange won't fire; tell the parent it's dirty so Save can + // enable. Only real keystrokes count, not the programmatic 'reset' MUI + // fires when the committed value changes; and without allowNew a typed + // name is reverted on blur, so nothing was really edited. + if (reason === 'input' && allowNew) onNewDraft?.(); }} onChange={(_e, v) => resolveExisting(v)} onBlur={() => resolveExisting(inputVal)}