From e07c9c69cf26ac858f6893972492b3c8fe73381a Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 11:06:52 -0800 Subject: [PATCH 01/14] use object instead of string as single source of truth --- src/components/Dropdown/index.tsx | 4 +- src/components/GradientInput/index.tsx | 15 +- src/components/InputSwitch/index.tsx | 12 +- src/components/JSONViewer/index.tsx | 9 +- src/components/PackingInput/index.tsx | 22 +-- src/state/store.ts | 211 ++++++++++++------------- src/state/utils.ts | 13 ++ src/types/index.ts | 24 ++- src/utils/firebase.ts | 47 ++++-- 9 files changed, 181 insertions(+), 176 deletions(-) create mode 100644 src/state/utils.ts diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index 055fd959..ab9bd641 100644 --- a/src/components/Dropdown/index.tsx +++ b/src/components/Dropdown/index.tsx @@ -12,8 +12,8 @@ interface DropdownProps { const Dropdown = (props: DropdownProps): JSX.Element => { const { placeholder, options, onChange, defaultValue } = props; const selectOptions = map(options, (opt, key) => ({ - label: opt.name || key, - value: opt.recipe, + label: opt.displayName || key, + value: opt.recipeId, })); return ( diff --git a/src/components/GradientInput/index.tsx b/src/components/GradientInput/index.tsx index bfbd8fac..b4e5d138 100644 --- a/src/components/GradientInput/index.tsx +++ b/src/components/GradientInput/index.tsx @@ -2,9 +2,8 @@ import { Select, Slider, InputNumber } from "antd"; import { GradientOption } from "../../types"; import { useSelectedRecipeId, - useUpdateRecipeObj, useGetCurrentValue, - useCurrentRecipeString, + useEditRecipe, } from "../../state/store"; import { getSelectedGradient, deriveGradientStrength, round2, toStore } from "../../utils/gradient"; import "./style.css"; @@ -19,10 +18,8 @@ interface GradientInputProps { const GradientInput = (props: GradientInputProps): JSX.Element => { const { displayName, description, gradientOptions, defaultValue } = props; const selectedRecipeId = useSelectedRecipeId(); - const updateRecipeObj = useUpdateRecipeObj(); + const editRecipe = useEditRecipe(); const getCurrentValue = useGetCurrentValue(); - // Force re-render after restore/navigation - useCurrentRecipeString(); const { currentGradient, selectedOption } = getSelectedGradient( gradientOptions, @@ -36,20 +33,16 @@ const GradientInput = (props: GradientInputProps): JSX.Element => { if (!selectedRecipeId) return; const selectedOption = gradientOptions.find(option => option.value === value); if (!selectedOption || !selectedOption.path) return; - - // Make changes to JSON recipe - const changes: Record = {[selectedOption.path]: value}; if (selectedOption.packing_mode && selectedOption.packing_mode_path) { - changes[selectedOption.packing_mode_path] = selectedOption.packing_mode; + editRecipe(selectedRecipeId, selectedOption.packing_mode_path, selectedOption.packing_mode); } - updateRecipeObj(selectedRecipeId, changes); }; const handleStrengthChange = (val: number | null) => { if (val == null || !selectedRecipeId || !gradientStrengthData) return; const uiVal = round2(val); const storeVal = toStore(uiVal); - updateRecipeObj(selectedRecipeId, { [gradientStrengthData.path]: storeVal }); + editRecipe(selectedRecipeId, gradientStrengthData.path, storeVal); }; const selectOptions = gradientOptions.map((option) => ({ diff --git a/src/components/InputSwitch/index.tsx b/src/components/InputSwitch/index.tsx index 07ceb9b1..c830cb54 100644 --- a/src/components/InputSwitch/index.tsx +++ b/src/components/InputSwitch/index.tsx @@ -3,9 +3,9 @@ import { Input, InputNumber, Select, Slider } from "antd"; import { GradientOption } from "../../types"; import { useSelectedRecipeId, - useUpdateRecipeObj, useGetCurrentValue, - useCurrentRecipeString, + useEditRecipe, + useRecipes, } from "../../state/store"; import GradientInput from "../GradientInput"; import "./style.css"; @@ -28,9 +28,9 @@ const InputSwitch = (props: InputSwitchProps): JSX.Element => { const { displayName, inputType, dataType, description, min, max, options, id, gradientOptions, conversionFactor, unit } = props; const selectedRecipeId = useSelectedRecipeId(); - const updateRecipeObj = useUpdateRecipeObj(); + const editRecipe = useEditRecipe(); const getCurrentValue = useGetCurrentValue(); - const recipeVersion = useCurrentRecipeString(); + const recipes = useRecipes(); // Conversion factor for numeric inputs where we want to display a // different unit in the UI than is stored in the recipe @@ -59,7 +59,7 @@ const InputSwitch = (props: InputSwitchProps): JSX.Element => { // Reset local state when store value (or recipe) changes useEffect(() => { setValue(getCurrentValueMemo()); - }, [getCurrentValueMemo, recipeVersion]); + }, [getCurrentValueMemo, recipes]); const handleInputChange = (value: string | number | null) => { if (value == null || !selectedRecipeId) return; @@ -68,7 +68,7 @@ const InputSwitch = (props: InputSwitchProps): JSX.Element => { // Convert back to original units for updating recipe object value = value / conversion; } - updateRecipeObj(selectedRecipeId, { [id]: value }); + editRecipe(selectedRecipeId, id, value); }; switch (inputType) { diff --git a/src/components/JSONViewer/index.tsx b/src/components/JSONViewer/index.tsx index 2e83b403..65293b68 100644 --- a/src/components/JSONViewer/index.tsx +++ b/src/components/JSONViewer/index.tsx @@ -7,12 +7,11 @@ import { returnOneElement, } from "./formattingUtils"; import "./style.css"; +import { ViewableRecipe } from "../../types"; interface JSONViewerProps { title: string; - content: string; - isEditable: boolean; - onChange: (value: string) => void; + content?: ViewableRecipe; } const JSONViewer = (props: JSONViewerProps): JSX.Element | null => { @@ -22,8 +21,6 @@ const JSONViewer = (props: JSONViewerProps): JSX.Element | null => { return null; } - const contentAsObj = JSON.parse(content); - // descriptions for top level key-value pairs const descriptions: DescriptionsItemProps[] = []; // trees for nested objects like 'objects', 'composition', 'gradients' @@ -44,7 +41,7 @@ const JSONViewer = (props: JSONViewerProps): JSX.Element | null => { }; // top level objects, like name, bounding_box, etc. - Object.entries(contentAsObj).forEach(([key, value]) => { + Object.entries(content).forEach(([key, value]) => { if (typeof value === "string") { descriptions.push({ label: convertUnderscoreToSpace(key), diff --git a/src/components/PackingInput/index.tsx b/src/components/PackingInput/index.tsx index 4bcce8ed..88b1febb 100644 --- a/src/components/PackingInput/index.tsx +++ b/src/components/PackingInput/index.tsx @@ -3,15 +3,13 @@ import { Tabs } from "antd"; import { useSelectedRecipeId, - useCurrentRecipeString, - useFieldsToDisplay, - useInputOptions, useIsLoading, - useLoadInputOptions, useSelectRecipe, - useUpdateRecipeString, useStartPacking, useLoadAllRecipes, + useCurrentRecipeObject, + useInputOptions, + useLoadInputOptions, } from "../../state/store"; import Dropdown from "../Dropdown"; import JSONViewer from "../JSONViewer"; @@ -29,15 +27,13 @@ interface PackingInputProps { const PackingInput = (props: PackingInputProps): JSX.Element => { const { startPacking } = props; const selectedRecipeId = useSelectedRecipeId(); - const recipeString = useCurrentRecipeString(); - const fieldsToDisplay = useFieldsToDisplay(); + const recipeObj = useCurrentRecipeObject(); const inputOptions = useInputOptions(); const isLoading = useIsLoading(); const loadInputOptions = useLoadInputOptions(); const loadAllRecipes = useLoadAllRecipes(); const selectRecipe = useSelectRecipe(); - const updateRecipeString = useUpdateRecipeString(); const storeStartPacking = useStartPacking(); const preFetchInputsAndRecipes = useCallback(async () => { @@ -54,12 +50,6 @@ const PackingInput = (props: PackingInputProps): JSX.Element => { await storeStartPacking(startPacking); }; - const handleRecipeStringChange = (newString: string) => { - if (selectedRecipeId) { - updateRecipeString(selectedRecipeId, newString); - } - }; - if (isLoading) { return
Loading...
; } @@ -82,9 +72,7 @@ const PackingInput = (props: PackingInputProps): JSX.Element => { diff --git a/src/state/store.ts b/src/state/store.ts index fae18ab6..c9e2bda3 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,17 +1,12 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -import { get as lodashGet, set as lodashSet } from "lodash-es"; -import { PackingResults, RecipeManifest } from "../types"; -import { getFirebaseRecipe, jsonToString } from "../utils/recipeLoader"; -import { getPackingInputsDict } from "../utils/firebase"; +import { isEqual, get as lodashGet } from "lodash-es"; +import { PackingResults, RecipeData, RecipeManifest } from "../types"; +import { jsonToString } from "../utils/recipeLoader"; +import { getRecipeDataFromFirebase, getRecipesFromFirebase } from "../utils/firebase"; import { EMPTY_PACKING_RESULTS } from "./constants"; +import { buildCurrentRecipeObject } from "./utils"; -export interface RecipeData { - id: string; - originalString: string; - currentString: string; - isModified: boolean; -} export interface RecipeState { selectedRecipeId: string; @@ -27,14 +22,10 @@ export interface UIState { type Actions = { loadInputOptions: () => Promise; - loadAllRecipes: () => Promise; - selectRecipe: (inputName: string) => Promise; loadRecipe: (recipeId: string) => Promise; - updateRecipeString: (recipeId: string, newString: string) => void; - updateRecipeObj: ( - recipeId: string, - updates: Record - ) => void; + loadAllRecipes: () => Promise; + selectRecipe: (recipeId: string) => Promise; + editRecipe: (recipeID: string, path: string, value: string | number) => void; restoreRecipeDefault: (recipeId: string) => void; getCurrentValue: (path: string) => string | number | undefined; getOriginalValue: (path: string) => string | number | undefined; @@ -70,7 +61,7 @@ export const useRecipeStore = create()( loadInputOptions: async () => { set({ isLoading: true }); try { - const inputOptions = await getPackingInputsDict(); + const inputOptions = await getRecipesFromFirebase(); set({ inputOptions }); } finally { set({ isLoading: false }); @@ -79,17 +70,11 @@ export const useRecipeStore = create()( loadRecipe: async (recipeId) => { if (get().recipes[recipeId]) return; - const recJson = await getFirebaseRecipe(recipeId); - const recStr = jsonToString(recJson); + const rec = await getRecipeDataFromFirebase(recipeId); set((s) => ({ recipes: { ...s.recipes, - [recipeId]: { - id: recipeId, - originalString: recStr, - currentString: recStr, - isModified: false, - }, + [recipeId]: rec }, })); }, @@ -99,13 +84,13 @@ export const useRecipeStore = create()( const ids = new Set(); Object.values(inputOptions).forEach((opt) => { - if (opt?.recipe) ids.add(opt.recipe); + if (opt?.recipeId) ids.add(opt.recipeId); }); const recipesToLoad = [...ids].filter((id) => !recipes[id]); if (!recipesToLoad.length) return; set({ isLoading: true }); try { - await Promise.all(recipesToLoad.map((id) => loadRecipe(id))); + await Promise.all(recipesToLoad.map((id) => loadRecipe(id))); } finally { set({ isLoading: false }); } @@ -130,8 +115,8 @@ export const useRecipeStore = create()( selectedRecipeId: recipeId, }); - if (sel.recipe && !get().recipes[sel.recipe]) { - await get().loadRecipe(sel.recipe); + if (sel.recipeId && !get().recipes[sel.recipeId]) { + await get().loadRecipe(sel.recipeId); } }, @@ -156,70 +141,69 @@ export const useRecipeStore = create()( }); }, - updateRecipeString: (recipeId, newString) => { - set((s) => { - const rec = s.recipes[recipeId]; - if (!rec) return s; - return { - recipes: { - ...s.recipes, - [recipeId]: { - ...rec, - currentString: newString, - isModified: newString !== rec.originalString, - }, - }, - }; - }); - }, - updateRecipeObj: (recipeId, updates) => { + editRecipe: (recipeId, path, value) => { const rec = get().recipes[recipeId]; if (!rec) return; - try { - const obj = JSON.parse(rec.currentString); + const newEdits = { ...rec.edits }; - for (const [path, value] of Object.entries(updates)) { - lodashSet(obj, path, value); - } - get().updateRecipeString( - recipeId, - JSON.stringify(obj, null, 2) - ); - } catch { - // TODO: better error handling - console.warn("Failed to update recipe object"); + const defaultValue = lodashGet(rec.defaultRecipeData, path); + if (isEqual(defaultValue, value)) { + delete newEdits[path]; // no longer different from default + } else { + newEdits[path] = value; } - }, - restoreRecipeDefault: (recipeId) => { - const rec = get().recipes[recipeId]; - if (rec) get().updateRecipeString(recipeId, rec.originalString); + set((state) => ({ + recipes: { + ...state.recipes, + [recipeId]: { + ...rec, + edits: newEdits + }, + }, + })); }, + getCurrentValue: (path) => { const { selectedRecipeId, recipes } = get(); - const str = recipes[selectedRecipeId]?.currentString; - if (!str) return undefined; - try { - const obj = JSON.parse(str); - const v = lodashGet(obj, path); - return typeof v === "string" || typeof v === "number" - ? v - : undefined; - } catch { - console.warn("Failed to retrieve value."); + const rec = recipes[selectedRecipeId]; + if (!rec) return undefined; + + // First check if an edited value exists at this path + const editedValue = lodashGet(rec.edits, path); + if (editedValue !== undefined) { + if (typeof editedValue === "string" || typeof editedValue === "number") { + return editedValue; + } return undefined; } + + // Otherwise, fall back to the default recipe + const defaultValue = lodashGet(rec.defaultRecipeData, path); + if (typeof defaultValue === "string" || typeof defaultValue === "number") { + return defaultValue; + } + + return undefined; + }, + + getOriginalValue: (path) => { + const { selectedRecipeId, recipes } = get(); + const rec = recipes[selectedRecipeId]?.defaultRecipeData; + if (!rec) return undefined; + const v = lodashGet(rec, path); + return (typeof v === "string" || typeof v === "number") ? v : undefined; }, startPacking: async (callback) => { const s = get(); const input = s.inputOptions[s.selectedRecipeId]; - const configId = input?.config ?? ""; - const recipeString = - s.recipes[s.selectedRecipeId]?.currentString ?? ""; + const configId = input?.configId ?? ""; + const recipe = s.recipes[s.selectedRecipeId]; + const recipeString = jsonToString(buildCurrentRecipeObject(recipe)) set({ isPacking: true }); try { await callback(s.selectedRecipeId, configId, recipeString); @@ -228,42 +212,36 @@ export const useRecipeStore = create()( } }, - getOriginalValue: (path) => { - const { selectedRecipeId, recipes } = get(); - const str = recipes[selectedRecipeId]?.originalString; - if (!str) return undefined; - try { - const obj = JSON.parse(str); - const v = lodashGet(obj, path); - return typeof v === "string" || typeof v === "number" - ? v - : undefined; - } catch { - console.warn("Failed to retrieve default value."); - return undefined; - } + restoreRecipeDefault: (recipeId) => { + set(state => { + const rec = state.recipes[recipeId]; + if (!rec) return {}; + return { + recipes: { + ...state.recipes, + [recipeId]: { + ...rec, + edits: {}, + }, + }, + }; + }); }, + })) ); -// simple selectors -export const useSelectedRecipeId = () => - useRecipeStore((s) => s.selectedRecipeId); -export const useCurrentRecipeString = () => - useRecipeStore((s) => s.recipes[s.selectedRecipeId]?.currentString ?? ""); +// Basic selectors +export const useSelectedRecipeId = () => useRecipeStore(s => s.selectedRecipeId); export const useInputOptions = () => useRecipeStore((s) => s.inputOptions); - -export const useIsLoading = () => useRecipeStore((s) => s.isLoading); -export const useIsPacking = () => useRecipeStore((s) => s.isPacking); +export const useIsLoading = () => useRecipeStore(s => s.isLoading); +export const useIsPacking = () => useRecipeStore(s => s.isPacking); export const useFieldsToDisplay = () => - useRecipeStore((s) => s.inputOptions[s.selectedRecipeId]?.editable_fields); -export const useIsCurrentRecipeModified = () => - useRecipeStore((s) => s.recipes[s.selectedRecipeId]?.isModified ?? false); -export const useGetOriginalValue = () => - useRecipeStore((s) => s.getOriginalValue); -const usePackingResults = () => useRecipeStore((s) => s.packingResults); + useRecipeStore((s) => s.inputOptions[s.selectedRecipeId]?.editableFields); +export const useRecipes = () => useRecipeStore(s => s.recipes) +export const usePackingResults = () => useRecipeStore(s => s.packingResults); -// compound selectors +// Compound selectors const useCurrentRecipeManifest = () => { const selectedRecipeId = useSelectedRecipeId(); @@ -271,7 +249,19 @@ const useCurrentRecipeManifest = () => { if (!selectedRecipeId) return undefined; return inputOptions[selectedRecipeId]; }; -const useDefaultResultPath = () => { + +export const useCurrentRecipeData = () => { + const selectedRecipeId = useSelectedRecipeId(); + const recipes = useRecipes(); + return recipes[selectedRecipeId] || undefined; +} + +export const useCurrentRecipeObject = () => { + const recipe = useCurrentRecipeData(); + return recipe ? buildCurrentRecipeObject(recipe) : undefined; +} + +export const useDefaultResultPath = () => { const manifest = useCurrentRecipeManifest(); return manifest?.defaultResultPath || ""; }; @@ -309,20 +299,19 @@ export const useResultUrl = () => { return path; }; -// action selectors (stable identities) +// Action selectors export const useLoadInputOptions = () => useRecipeStore((s) => s.loadInputOptions); export const useLoadAllRecipes = () => useRecipeStore((s) => s.loadAllRecipes); export const useSelectRecipe = () => useRecipeStore((s) => s.selectRecipe); -export const useUpdateRecipeObj = () => - useRecipeStore((s) => s.updateRecipeObj); -export const useUpdateRecipeString = () => - useRecipeStore((s) => s.updateRecipeString); +export const useEditRecipe = () => useRecipeStore(s => s.editRecipe); export const useRestoreRecipeDefault = () => useRecipeStore((s) => s.restoreRecipeDefault); export const useStartPacking = () => useRecipeStore((s) => s.startPacking); export const useGetCurrentValue = () => useRecipeStore((s) => s.getCurrentValue); +export const useGetOriginalValue = () => + useRecipeStore((s) => s.getOriginalValue); export const useSetPackingResults = () => useRecipeStore((s) => s.setPackingResults); export const useSetJobLogs = () => useRecipeStore((s) => s.setJobLogs); diff --git a/src/state/utils.ts b/src/state/utils.ts new file mode 100644 index 00000000..20ca528d --- /dev/null +++ b/src/state/utils.ts @@ -0,0 +1,13 @@ +import { RecipeData } from "../types"; +import { set } from "lodash-es"; + +/** + * Build a recipe from a default and a set of edits. + */ +export const buildCurrentRecipeObject = (recipe: RecipeData) => { + const clone = structuredClone(recipe.defaultRecipeData); + for (const [path, value] of Object.entries(recipe.edits)) { + set(clone, path, value); + } + return clone; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 3782d021..0d5df43f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,13 +16,25 @@ export interface Dictionary { [Key: string]: T; } -export type RecipeManifest = { - name?: string; - config: string; - recipe: string; +// TODO further refine difference between data and metadata, decide how +// to store them, and when to load them. + +export interface RecipeData { + recipeId: string; + defaultRecipeData: ViewableRecipe; + edits: Record; +} + +export interface RecipeManifest { + recipeId: string; + configId: string; + displayName: string; + editableFields: EditableField[]; defaultResultPath?: string; - editable_fields?: EditableField[]; -}; + defaultRecipeData: ViewableRecipe; + edits: Record; +} + export type JobStatusObject = { status: string; diff --git a/src/utils/firebase.ts b/src/utils/firebase.ts index 04c5a879..a6e80cde 100644 --- a/src/utils/firebase.ts +++ b/src/utils/firebase.ts @@ -25,7 +25,9 @@ import { Dictionary, EditableField, JobStatusObject, + RecipeData, } from "../types"; +import { getFirebaseRecipe } from "./recipeLoader"; const getEnvVar = (key: string): string => { // check if we're in a browser environment (Vite) @@ -163,25 +165,35 @@ const getEditableFieldsList = async ( return docs; }; -const getPackingInputsDict = async (): Promise> => { - const docs = await getAllDocsFromCollection( - FIRESTORE_COLLECTIONS.PACKING_INPUTS - ); +// TODO get data and metadata separately +const getRecipeDataFromFirebase = async (recipeId: string): Promise => { + const defaultRecipeData = await getFirebaseRecipe(recipeId); + return { + recipeId, + defaultRecipeData, + edits: {} + } +} + +const getRecipesFromFirebase = async (): Promise> => { + const docs = await getAllDocsFromCollection(FIRESTORE_COLLECTIONS.PACKING_INPUTS); const inputsDict: Dictionary = {}; for (const doc of docs) { - const displayName = doc[FIRESTORE_FIELDS.NAME]; + const name = doc[FIRESTORE_FIELDS.NAME]; const config = doc[FIRESTORE_FIELDS.CONFIG]; - const recipe = doc[FIRESTORE_FIELDS.RECIPE]; - const editableFields = await getEditableFieldsList( - doc[FIRESTORE_FIELDS.EDITABLE_FIELDS] || [] - ); - const result = doc[FIRESTORE_FIELDS.RESULT_PATH] || ""; - if (config && recipe) { - inputsDict[recipe] = { - [FIRESTORE_FIELDS.NAME]: displayName, - [FIRESTORE_FIELDS.CONFIG]: config, - [FIRESTORE_FIELDS.RECIPE]: recipe, - [FIRESTORE_FIELDS.EDITABLE_FIELDS]: editableFields, + const recipeId = doc[FIRESTORE_FIELDS.RECIPE]; + + if (name && config && recipeId) { + const editableFields = await getEditableFieldsList(doc[FIRESTORE_FIELDS.EDITABLE_FIELDS] || []); + const recipe = await getFirebaseRecipe(recipeId); + const result = doc[FIRESTORE_FIELDS.RESULT_PATH] || ""; + inputsDict[recipeId] = { + recipeId: recipeId, + configId: config, + displayName: name, + editableFields: editableFields ?? [], + defaultRecipeData: recipe, + edits: {}, defaultResultPath: result, }; } @@ -249,6 +261,7 @@ export { getJobStatus, addRecipe, docCleanup, - getPackingInputsDict, + getRecipesFromFirebase, + getRecipeDataFromFirebase, getOutputsDirectory, }; From 3a02c075ec9806d2dd7b61e05727afcf2ca4684c Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 11:36:31 -0800 Subject: [PATCH 02/14] use displayName --- src/utils/firebase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/firebase.ts b/src/utils/firebase.ts index a6e80cde..b2e11f61 100644 --- a/src/utils/firebase.ts +++ b/src/utils/firebase.ts @@ -179,18 +179,18 @@ const getRecipesFromFirebase = async (): Promise> => const docs = await getAllDocsFromCollection(FIRESTORE_COLLECTIONS.PACKING_INPUTS); const inputsDict: Dictionary = {}; for (const doc of docs) { - const name = doc[FIRESTORE_FIELDS.NAME]; + const displayName = doc[FIRESTORE_FIELDS.NAME]; const config = doc[FIRESTORE_FIELDS.CONFIG]; const recipeId = doc[FIRESTORE_FIELDS.RECIPE]; - if (name && config && recipeId) { + if (displayName && config && recipeId) { const editableFields = await getEditableFieldsList(doc[FIRESTORE_FIELDS.EDITABLE_FIELDS] || []); const recipe = await getFirebaseRecipe(recipeId); const result = doc[FIRESTORE_FIELDS.RESULT_PATH] || ""; inputsDict[recipeId] = { recipeId: recipeId, configId: config, - displayName: name, + displayName, editableFields: editableFields ?? [], defaultRecipeData: recipe, edits: {}, From 0c886b58d55d5d9b3b5b2226769f83200bdd613a Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 19:14:42 -0800 Subject: [PATCH 03/14] split manifest into RecipeMetadata and RecipeData and refactor prefetching --- src/components/Dropdown/index.tsx | 4 +- src/components/PackingInput/index.tsx | 35 ++++++----- src/state/store.ts | 86 +++++++++++++-------------- src/types/index.ts | 20 +++---- src/utils/firebase.ts | 51 +++++++++------- 5 files changed, 101 insertions(+), 95 deletions(-) diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index ab9bd641..da0a1885 100644 --- a/src/components/Dropdown/index.tsx +++ b/src/components/Dropdown/index.tsx @@ -1,11 +1,11 @@ import { Select } from "antd"; import { map } from "lodash-es"; -import { Dictionary, RecipeManifest } from "../../types"; +import { Dictionary, RecipeMetadata } from "../../types"; interface DropdownProps { placeholder: string; defaultValue?: string; - options: Dictionary; + options: Dictionary; onChange: (value: string) => void; } diff --git a/src/components/PackingInput/index.tsx b/src/components/PackingInput/index.tsx index 88b1febb..a68f4feb 100644 --- a/src/components/PackingInput/index.tsx +++ b/src/components/PackingInput/index.tsx @@ -3,7 +3,6 @@ import { Tabs } from "antd"; import { useSelectedRecipeId, - useIsLoading, useSelectRecipe, useStartPacking, useLoadAllRecipes, @@ -29,7 +28,6 @@ const PackingInput = (props: PackingInputProps): JSX.Element => { const selectedRecipeId = useSelectedRecipeId(); const recipeObj = useCurrentRecipeObject(); const inputOptions = useInputOptions(); - const isLoading = useIsLoading(); const loadInputOptions = useLoadInputOptions(); const loadAllRecipes = useLoadAllRecipes(); @@ -50,9 +48,12 @@ const PackingInput = (props: PackingInputProps): JSX.Element => { await storeStartPacking(startPacking); }; - if (isLoading) { - return
Loading...
; - } + const loadingText = (
Loading...
) + + // No recipe or dropdown options to load + if (!recipeObj && !inputOptions[selectedRecipeId]) { + return loadingText; + } return ( <> @@ -65,17 +66,19 @@ const PackingInput = (props: PackingInputProps): JSX.Element => { onChange={selectRecipe} /> - - - - - - - - + {/* Options menu loaded, but no recipe to load yet */} + {!recipeObj ? ( + loadingText + ) : ( + + + + + + + + + )} ); }; diff --git a/src/state/store.ts b/src/state/store.ts index 8c41c870..bbc27f63 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,22 +1,21 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -import { isEqual, get as lodashGet } from "lodash-es"; -import { PackingResults, RecipeData, RecipeManifest } from "../types"; +import { isEmpty, isEqual, get as lodashGet } from "lodash-es"; +import { PackingResults, RecipeData, RecipeMetadata } from "../types"; import { jsonToString } from "../utils/recipeLoader"; -import { getRecipeDataFromFirebase, getRecipesFromFirebase } from "../utils/firebase"; +import { getRecipeDataFromFirebase, getRecipeMetadataFromFirebase } from "../utils/firebase"; import { EMPTY_PACKING_RESULTS } from "./constants"; import { buildCurrentRecipeObject } from "./utils"; export interface RecipeState { selectedRecipeId: string; - inputOptions: Record; + inputOptions: Record; recipes: Record; packingResults: PackingResults; } export interface UIState { - isLoading: boolean; isPacking: boolean; } @@ -49,7 +48,6 @@ const initialState: RecipeState & UIState = { selectedRecipeId: INITIAL_RECIPE_ID, inputOptions: {}, recipes: {}, - isLoading: false, isPacking: false, packingResults: { ...EMPTY_PACKING_RESULTS }, }; @@ -59,18 +57,17 @@ export const useRecipeStore = create()( ...initialState, loadInputOptions: async () => { - set({ isLoading: true }); - try { - const inputOptions = await getRecipesFromFirebase(); - set({ inputOptions }); - } finally { - set({ isLoading: false }); - } + // Early return to prevent re-querying after options have loaded + if (!isEmpty(get().inputOptions)) return; + const inputOptions = await getRecipeMetadataFromFirebase(); + set({ inputOptions }); }, loadRecipe: async (recipeId) => { - if (get().recipes[recipeId]) return; - const rec = await getRecipeDataFromFirebase(recipeId); + const { recipes, inputOptions } = get(); + if (recipes[recipeId]) return; + const editableFieldIds = inputOptions[recipeId].editableFieldIds; + const rec = await getRecipeDataFromFirebase(recipeId, editableFieldIds); set((s) => ({ recipes: { ...s.recipes, @@ -80,28 +77,29 @@ export const useRecipeStore = create()( }, loadAllRecipes: async () => { - const { inputOptions, recipes, loadRecipe } = get(); - - const ids = new Set(); - Object.values(inputOptions).forEach((opt) => { - if (opt?.recipeId) ids.add(opt.recipeId); - }); - const recipesToLoad = [...ids].filter((id) => !recipes[id]); - if (!recipesToLoad.length) return; - set({ isLoading: true }); - try { - await Promise.all(recipesToLoad.map((id) => loadRecipe(id))); - } finally { - set({ isLoading: false }); - } - let recipeToLoad = INITIAL_RECIPE_ID; - if (!get().recipes[INITIAL_RECIPE_ID]) { - console.warn( - `Initial recipe ID ${INITIAL_RECIPE_ID} not found, selecting first available recipe.` - ); - recipeToLoad = Object.keys(get().recipes)[0]; + const { inputOptions, loadRecipe } = get(); + + const optionList = Object.values(inputOptions || {}); + if (optionList.length === 0) return; + + const recipeIds = optionList + .map(o => o?.recipeId) + .filter(id => id && !get().recipes[id]); + + // Make sure our default initial is in the options we queried + const initialIdToLoad = + recipeIds.includes(INITIAL_RECIPE_ID) ? INITIAL_RECIPE_ID : recipeIds[0]; + + // Ensure the bootstrap recipe is loaded & selected + if (!get().recipes[initialIdToLoad]) { + await loadRecipe(initialIdToLoad); } - get().selectRecipe(recipeToLoad); + + // Load remaining recipes in the background (don’t block) + const remainingRecipesToLoad = recipeIds.filter( + id => id !== initialIdToLoad && !get().recipes[id] + ); + await Promise.all(remainingRecipesToLoad.map((id) => loadRecipe(id))); }, selectRecipe: async (recipeId) => { @@ -141,7 +139,6 @@ export const useRecipeStore = create()( }); }, - editRecipe: (recipeId, path, value) => { const rec = get().recipes[recipeId]; if (!rec) return; @@ -234,16 +231,15 @@ export const useRecipeStore = create()( // Basic selectors export const useSelectedRecipeId = () => useRecipeStore(s => s.selectedRecipeId); export const useInputOptions = () => useRecipeStore((s) => s.inputOptions); -export const useIsLoading = () => useRecipeStore(s => s.isLoading); export const useIsPacking = () => useRecipeStore(s => s.isPacking); export const useFieldsToDisplay = () => - useRecipeStore((s) => s.inputOptions[s.selectedRecipeId]?.editableFields); + useRecipeStore((s) => s.recipes[s.selectedRecipeId]?.editableFields); export const useRecipes = () => useRecipeStore(s => s.recipes) export const usePackingResults = () => useRecipeStore(s => s.packingResults); // Compound selectors -const useCurrentRecipeManifest = () => { +const useCurrentRecipeMetadata = () => { const selectedRecipeId = useSelectedRecipeId(); const inputOptions = useInputOptions(); if (!selectedRecipeId) return undefined; @@ -258,12 +254,16 @@ export const useCurrentRecipeData = () => { export const useCurrentRecipeObject = () => { const recipe = useCurrentRecipeData(); - return recipe ? buildCurrentRecipeObject(recipe) : undefined; + return recipe ? buildCurrentRecipeObject(recipe) : undefined; } export const useDefaultResultPath = () => { - const manifest = useCurrentRecipeManifest(); - return manifest?.defaultResultPath || ""; + const manifest = useCurrentRecipeMetadata(); + // the default URL is stored in the metadata which loads before + // the recipe is queried, using both data here prevents the viewer + // loading ahead of the recipe + const recipe = useCurrentRecipeData(); + return (recipe && manifest?.defaultResultPath) || ""; }; export const useRunTime = () => { diff --git a/src/types/index.ts b/src/types/index.ts index 0d5df43f..2359ac19 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,26 +16,24 @@ export interface Dictionary { [Key: string]: T; } -// TODO further refine difference between data and metadata, decide how -// to store them, and when to load them. - -export interface RecipeData { +// The fields in RecipeMetadata are available immeidately when we run +// getAllDocsFromCollection +export interface RecipeMetadata { recipeId: string; - defaultRecipeData: ViewableRecipe; - edits: Record; + configId: string; + displayName: string; + editableFieldIds: string[]; + defaultResultPath?: string; } -export interface RecipeManifest { +export interface RecipeData { recipeId: string; - configId: string; - displayName: string; + defaultRecipeData: ViewableRecipe; editableFields: EditableField[]; defaultResultPath?: string; - defaultRecipeData: ViewableRecipe; edits: Record; } - export type JobStatusObject = { status: string; error_message: string; diff --git a/src/utils/firebase.ts b/src/utils/firebase.ts index b2e11f61..0a7fd175 100644 --- a/src/utils/firebase.ts +++ b/src/utils/firebase.ts @@ -21,11 +21,11 @@ import { } from "../constants/firebase"; import { FirestoreDoc, - RecipeManifest, Dictionary, EditableField, JobStatusObject, RecipeData, + RecipeMetadata, } from "../types"; import { getFirebaseRecipe } from "./recipeLoader"; @@ -165,41 +165,46 @@ const getEditableFieldsList = async ( return docs; }; -// TODO get data and metadata separately -const getRecipeDataFromFirebase = async (recipeId: string): Promise => { - const defaultRecipeData = await getFirebaseRecipe(recipeId); - return { - recipeId, - defaultRecipeData, - edits: {} - } -} - -const getRecipesFromFirebase = async (): Promise> => { +/** + * These RecipeMetadata fields are queried as a group, and quick to retrieve from the backend. + * Slower loading fields are defined as RecipeData and queried individually. + */ +const getRecipeMetadataFromFirebase = async (): Promise> => { const docs = await getAllDocsFromCollection(FIRESTORE_COLLECTIONS.PACKING_INPUTS); - const inputsDict: Dictionary = {}; + const inputsDict: Dictionary = {}; for (const doc of docs) { const displayName = doc[FIRESTORE_FIELDS.NAME]; const config = doc[FIRESTORE_FIELDS.CONFIG]; const recipeId = doc[FIRESTORE_FIELDS.RECIPE]; + const editableFieldIds = doc[FIRESTORE_FIELDS.EDITABLE_FIELDS]; + const defaultResultPath = doc[FIRESTORE_FIELDS.RESULT_PATH] || ""; - if (displayName && config && recipeId) { - const editableFields = await getEditableFieldsList(doc[FIRESTORE_FIELDS.EDITABLE_FIELDS] || []); - const recipe = await getFirebaseRecipe(recipeId); - const result = doc[FIRESTORE_FIELDS.RESULT_PATH] || ""; + if (displayName && config && recipeId) { inputsDict[recipeId] = { recipeId: recipeId, configId: config, displayName, - editableFields: editableFields ?? [], - defaultRecipeData: recipe, - edits: {}, - defaultResultPath: result, + editableFieldIds: editableFieldIds || [], + defaultResultPath }; } } return inputsDict; -}; +} + +/** + * Querying the recipe and the editable fields is slower. Can be called individually + */ +const getRecipeDataFromFirebase = async (recipeId: string, editableFieldIds: string[]): Promise => { + const defaultRecipeData = await getFirebaseRecipe(recipeId); + const editableFields = await getEditableFieldsList(editableFieldIds) || []; + return { + recipeId, + defaultRecipeData, + editableFields, + edits: {} + } +} const getDocsByIds = async (coll: string, ids: string[]) => { const querySnapshot = await queryDocumentsByIds(coll, ids); @@ -261,7 +266,7 @@ export { getJobStatus, addRecipe, docCleanup, - getRecipesFromFirebase, + getRecipeMetadataFromFirebase, getRecipeDataFromFirebase, getOutputsDirectory, }; From b8b29482f2b88d8e1ee9d5b533d26c16738b6531 Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 19:23:46 -0800 Subject: [PATCH 04/14] add loading overlay to viewer --- src/components/Viewer/index.tsx | 22 +++++++++++++++++++++- src/components/Viewer/style.css | 29 +++++++++++++++++++++++++++++ src/style/themeRoot.tsx | 6 ++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/components/Viewer/index.tsx b/src/components/Viewer/index.tsx index 8a2dc79d..6b1e27af 100644 --- a/src/components/Viewer/index.tsx +++ b/src/components/Viewer/index.tsx @@ -1,15 +1,35 @@ +import { LoadingOutlined } from "@ant-design/icons"; import { SIMULARIUM_EMBED_URL } from "../../constants/urls"; -import { useResultUrl } from "../../state/store"; +import { useCurrentRecipeObject, useIsPacking, useResultUrl } from "../../state/store"; import "./style.css"; const Viewer = (): JSX.Element => { const resultUrl = useResultUrl(); + const recipeObj = useCurrentRecipeObject(); + const isPacking = useIsPacking(); + + const overlayText = isPacking + ? "Running..." + : !recipeObj + ? "Loading..." + : ""; + + const showOverlay = !recipeObj || isPacking; + return (