diff --git a/src/renderer/src/components/Discussions/DiscussionCard.tsx b/src/renderer/src/components/Discussions/DiscussionCard.tsx index b582b535..84a906e0 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(); @@ -725,10 +728,19 @@ 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 = () => 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 = 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 +782,7 @@ export const DiscussionCard = (props: IProps) => { discussion, 'artifactCategory', 'artifactcategory', - editCategory, + catId, user ), ...UpdateRelatedRecord( @@ -988,6 +1000,8 @@ export const DiscussionCard = (props: IProps) => { id={`category-${discussion.id}`} initCategory={editCategory} onCategoryChange={onCategoryChange} + onNewDraft={onCategoryNewDraft} + 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..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 } from 'react'; +import React, { useEffect, useState, type RefObject } 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?: RefObject<(() => 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..efd70e5b 100644 --- a/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx +++ b/src/renderer/src/components/ResourceEdit/ResourceCategory.tsx @@ -1,23 +1,39 @@ +import type { RefObject } 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?: RefObject<(() => Promise) | null> | undefined; +} + +export const ResourceCategory = (props: IProps) => { + const { state, setState, commitRef } = props; const { category, note } = state; const handleChange = (category: string) => { 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 = () => { + setState && + setState((state) => (state.changed ? state : { ...state, changed: true })); + }; + return ( ); }; 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..82ba02f5 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 RefObject } from 'react'; import { ArtifactCategoryType, IArtifactCategory, @@ -30,6 +30,16 @@ 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?: RefObject<(() => Promise) | null> | undefined; + // 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?: (() => void) | undefined; } const StyledBox = styled(Box)(() => ({ @@ -57,6 +67,8 @@ export const SelectArtifactCategory = (props: IProps) => { scripture, type, disabled, + commitRef, + onNewDraft, } = props; const artifactCategories = useOrbitData('artifactcategory'); @@ -109,9 +121,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 +146,39 @@ 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(); + // 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 ''; + } + 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 +186,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 ( { .sort((a, b) => (a < b ? -1 : 1))} value={currentName || null} inputValue={inputVal} - onInputChange={(_e, v) => setInputVal(v)} - onChange={(_e, v) => resolveCommit(v)} - onBlur={() => resolveCommit(inputVal)} + onInputChange={(_e, v, reason) => { + setInputVal(v); + // 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)} sx={textFieldProps} renderOption={(liProps, option, { index }) => { const cat = artifactCategorys.find((c) => c.category === option); @@ -174,8 +243,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 && ( diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 0a618214..13573822 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); @@ -156,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; @@ -393,6 +398,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 && ![