From e07c9c69cf26ac858f6893972492b3c8fe73381a Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 11:06:52 -0800 Subject: [PATCH 1/9] 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 2/9] 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 ed152a68f33b0dc3db97e7878659722437fb6a56 Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Mon, 10 Nov 2025 19:51:45 -0800 Subject: [PATCH 3/9] add second call of editRecipe for gradient value --- src/components/GradientInput/index.tsx | 1 + src/state/store.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/GradientInput/index.tsx b/src/components/GradientInput/index.tsx index b4e5d138..93a8e91a 100644 --- a/src/components/GradientInput/index.tsx +++ b/src/components/GradientInput/index.tsx @@ -36,6 +36,7 @@ const GradientInput = (props: GradientInputProps): JSX.Element => { if (selectedOption.packing_mode && selectedOption.packing_mode_path) { editRecipe(selectedRecipeId, selectedOption.packing_mode_path, selectedOption.packing_mode); } + editRecipe(selectedRecipeId, selectedOption.path, value); }; const handleStrengthChange = (val: number | null) => { diff --git a/src/state/store.ts b/src/state/store.ts index 8c41c870..a32b67b5 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -258,7 +258,7 @@ export const useCurrentRecipeData = () => { export const useCurrentRecipeObject = () => { const recipe = useCurrentRecipeData(); - return recipe ? buildCurrentRecipeObject(recipe) : undefined; + return recipe ? buildCurrentRecipeObject(recipe) : undefined; } export const useDefaultResultPath = () => { From 9de4abf34e96735be5a248330c69b70b4154f8ad Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Tue, 11 Nov 2025 11:51:08 -0800 Subject: [PATCH 4/9] move building current recipe object from util into selector --- src/state/store.ts | 37 ++++++++++++++++++++++++++----------- src/state/utils.ts | 13 ------------- 2 files changed, 26 insertions(+), 24 deletions(-) delete mode 100644 src/state/utils.ts diff --git a/src/state/store.ts b/src/state/store.ts index a32b67b5..2498bb07 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,13 +1,10 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -import { isEqual, get as lodashGet } from "lodash-es"; +import { isEqual, get as lodashGet, set as lodashSet } 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 RecipeState { selectedRecipeId: string; inputOptions: Record; @@ -90,7 +87,7 @@ export const useRecipeStore = create()( 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 }); } @@ -202,8 +199,9 @@ export const useRecipeStore = create()( const s = get(); const input = s.inputOptions[s.selectedRecipeId]; const configId = input?.configId ?? ""; - const recipe = s.recipes[s.selectedRecipeId]; - const recipeString = jsonToString(buildCurrentRecipeObject(recipe)) + const recipeObject = selectCurrentRecipeObject(s); + if (!recipeObject) return; + const recipeString = jsonToString(recipeObject); set({ isPacking: true }); try { await callback(s.selectedRecipeId, configId, recipeString); @@ -231,6 +229,7 @@ export const useRecipeStore = create()( })) ); + // Basic selectors export const useSelectedRecipeId = () => useRecipeStore(s => s.selectedRecipeId); export const useInputOptions = () => useRecipeStore((s) => s.inputOptions); @@ -242,6 +241,26 @@ export const useRecipes = () => useRecipeStore(s => s.recipes) export const usePackingResults = () => useRecipeStore(s => s.packingResults); // Compound selectors +// We need the following two selector because the current +// recipe object is used in store actions like startPacking. +// The hook pattern is used only in selectors and in components: +// pureSelector(store) vs +// useSelectedData = () => useRecipeStore(pureSelector) +// TODO we could choose to refactor all selectors to be pure selectors +// and build the hooks separately. +// pure selector +export const selectCurrentRecipeObject = (s: RecipeStore) => { + const recipe = s.recipes[s.selectedRecipeId]; + if (!recipe) return undefined; + const clone = structuredClone(recipe.defaultRecipeData); + for (const [path, value] of Object.entries(recipe.edits)) { + lodashSet(clone, path, value); + } + return clone; +}; +// hook +export const useCurrentRecipeObject = () => + useRecipeStore(selectCurrentRecipeObject); const useCurrentRecipeManifest = () => { const selectedRecipeId = useSelectedRecipeId(); @@ -256,10 +275,6 @@ export const useCurrentRecipeData = () => { return recipes[selectedRecipeId] || undefined; } -export const useCurrentRecipeObject = () => { - const recipe = useCurrentRecipeData(); - return recipe ? buildCurrentRecipeObject(recipe) : undefined; -} export const useDefaultResultPath = () => { const manifest = useCurrentRecipeManifest(); diff --git a/src/state/utils.ts b/src/state/utils.ts deleted file mode 100644 index 20ca528d..00000000 --- a/src/state/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -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; -}; From faf266df941741475f6145fc5e0d9cd1c3de2faf Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Wed, 12 Nov 2025 09:31:16 -0800 Subject: [PATCH 5/9] revert to using utility to build recipe objects --- src/state/store.ts | 30 +++++++----------------------- src/state/utils.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 23 deletions(-) create mode 100644 src/state/utils.ts diff --git a/src/state/store.ts b/src/state/store.ts index 0e5ff738..fc028b4e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,10 +1,11 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -import { isEqual, get as lodashGet, set as lodashSet } from "lodash-es"; +import { isEqual, get as lodashGet } from "lodash-es"; import { PackingResult, RecipeData, RecipeManifest } from "../types"; import { jsonToString } from "../utils/recipeLoader"; import { getRecipeDataFromFirebase, getRecipesFromFirebase } from "../utils/firebase"; import { EMPTY_PACKING_RESULT } from "./constants"; +import { buildRecipeObject } from "./utils"; export interface RecipeState { selectedRecipeId: string; @@ -212,7 +213,7 @@ export const useRecipeStore = create()( const s = get(); const input = s.inputOptions[s.selectedRecipeId]; const configId = input?.configId ?? ""; - const recipeObject = selectCurrentRecipeObject(s); + const recipeObject = buildRecipeObject(s.recipes[s.selectedRecipeId]); if (!recipeObject) return; const recipeString = jsonToString(recipeObject); set({ isPacking: true }); @@ -253,27 +254,10 @@ export const useFieldsToDisplay = () => export const useRecipes = () => useRecipeStore(s => s.recipes) export const usePackingResults = () => useRecipeStore(s => s.packingResults); -// Compound selectors -// We need the following two selector because the current -// recipe object is used in store actions like startPacking. -// The hook pattern is used only in selectors and in components: -// pureSelector(store) vs -// useSelectedData = () => useRecipeStore(pureSelector) -// TODO we could choose to refactor all selectors to be pure selectors -// and build the hooks separately. -// pure selector -export const selectCurrentRecipeObject = (s: RecipeStore) => { - const recipe = s.recipes[s.selectedRecipeId]; - if (!recipe) return undefined; - const clone = structuredClone(recipe.defaultRecipeData); - for (const [path, value] of Object.entries(recipe.edits)) { - lodashSet(clone, path, value); - } - return clone; -}; -// hook -export const useCurrentRecipeObject = () => - useRecipeStore(selectCurrentRecipeObject); +export const useCurrentRecipeObject = () => { + const recipe = useCurrentRecipeData(); + return recipe ? buildRecipeObject(recipe) : undefined; +} const useCurrentRecipeManifest = () => { const selectedRecipeId = useSelectedRecipeId(); diff --git a/src/state/utils.ts b/src/state/utils.ts new file mode 100644 index 00000000..402e4ac8 --- /dev/null +++ b/src/state/utils.ts @@ -0,0 +1,11 @@ +import { set as lodashSet } from "lodash-es"; +import { RecipeData, ViewableRecipe } from "../types"; + +export const buildRecipeObject = (recipe: RecipeData): ViewableRecipe => { + const clone = structuredClone(recipe.defaultRecipeData); + for (const [path, value] of Object.entries(recipe.edits)) { + lodashSet(clone, path, value); + } + return clone; +} + From 8f5f11aaa9c789612952e072d9b98e64cd95f25d Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Wed, 12 Nov 2025 11:05:24 -0800 Subject: [PATCH 6/9] add documenting comment to buildRecipeObject --- src/state/store.ts | 2 -- src/state/utils.ts | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/state/store.ts b/src/state/store.ts index fc028b4e..c5f22f27 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -272,8 +272,6 @@ export const useCurrentRecipeData = () => { return recipes[selectedRecipeId] || undefined; } - -export const useCurrentPackingResult = () => { const selectedRecipeId = useSelectedRecipeId(); const packingResults = usePackingResults(); diff --git a/src/state/utils.ts b/src/state/utils.ts index 402e4ac8..a807a4f8 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -1,6 +1,16 @@ import { set as lodashSet } from "lodash-es"; import { RecipeData, ViewableRecipe } from "../types"; +/** + * Builds a new recipe object by applying user edits to the default recipe data. + * Keeping this cloning process out of selector hooks prevents infinite re-render + * loops, and allows the utilty to be used inside store actions. + * @param recipe - The recipe data containing: + * - `defaultRecipeData`: the base (unmodified) ViewableRecipe. + * - `edits`: a record of path–value pairs representing user modifications. + * @returns A deep-cloned ViewableRecipe with all edits applied. + * Typically represents the "current" version of the selected recipe in state. + */ export const buildRecipeObject = (recipe: RecipeData): ViewableRecipe => { const clone = structuredClone(recipe.defaultRecipeData); for (const [path, value] of Object.entries(recipe.edits)) { From f6f6dca47b3d511fbe1627585c6a791d2c4ebcea Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Wed, 12 Nov 2025 19:57:47 -0800 Subject: [PATCH 7/9] rename and adjust typing of recipe object utility --- src/state/store.ts | 7 ++++--- src/state/utils.ts | 20 +++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/state/store.ts b/src/state/store.ts index c5f22f27..080fb9a9 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -5,7 +5,7 @@ import { PackingResult, RecipeData, RecipeManifest } from "../types"; import { jsonToString } from "../utils/recipeLoader"; import { getRecipeDataFromFirebase, getRecipesFromFirebase } from "../utils/firebase"; import { EMPTY_PACKING_RESULT } from "./constants"; -import { buildRecipeObject } from "./utils"; +import { applyChangesToNestedObject } from "./utils"; export interface RecipeState { selectedRecipeId: string; @@ -213,7 +213,8 @@ export const useRecipeStore = create()( const s = get(); const input = s.inputOptions[s.selectedRecipeId]; const configId = input?.configId ?? ""; - const recipeObject = buildRecipeObject(s.recipes[s.selectedRecipeId]); + const { defaultRecipeData, edits } = s.recipes[s.selectedRecipeId]; + const recipeObject = applyChangesToNestedObject(defaultRecipeData, edits); if (!recipeObject) return; const recipeString = jsonToString(recipeObject); set({ isPacking: true }); @@ -256,7 +257,7 @@ export const usePackingResults = () => useRecipeStore(s => s.packingResults); export const useCurrentRecipeObject = () => { const recipe = useCurrentRecipeData(); - return recipe ? buildRecipeObject(recipe) : undefined; + return recipe ? applyChangesToNestedObject(recipe.defaultRecipeData, recipe.edits) : undefined; } const useCurrentRecipeManifest = () => { diff --git a/src/state/utils.ts b/src/state/utils.ts index a807a4f8..1d3a6a25 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -1,19 +1,17 @@ import { set as lodashSet } from "lodash-es"; -import { RecipeData, ViewableRecipe } from "../types"; +import { ViewableRecipe } from "../types"; /** - * Builds a new recipe object by applying user edits to the default recipe data. - * Keeping this cloning process out of selector hooks prevents infinite re-render - * loops, and allows the utilty to be used inside store actions. - * @param recipe - The recipe data containing: - * - `defaultRecipeData`: the base (unmodified) ViewableRecipe. - * - `edits`: a record of path–value pairs representing user modifications. + * Builds a new object by applying user edits (by path) to a default. + * Used to get the "current" version of the selected recipe + * in the UI. + * @param recipe - The default recipe object + * @param edits - A record of path–value pairs representing user modifications. * @returns A deep-cloned ViewableRecipe with all edits applied. - * Typically represents the "current" version of the selected recipe in state. */ -export const buildRecipeObject = (recipe: RecipeData): ViewableRecipe => { - const clone = structuredClone(recipe.defaultRecipeData); - for (const [path, value] of Object.entries(recipe.edits)) { +export const applyChangesToNestedObject = (recipe: ViewableRecipe, edits: Record): ViewableRecipe => { + const clone = structuredClone(recipe); + for (const [path, value] of Object.entries(edits)) { lodashSet(clone, path, value); } return clone; From 814d1f2b6b41473639814cc157f85b43ada5c22e Mon Sep 17 00:00:00 2001 From: meganrm Date: Wed, 12 Nov 2025 20:55:58 -0800 Subject: [PATCH 8/9] disable button if no parameters have been changed --- src/components/RecipeForm/index.tsx | 26 +++++++---- src/state/store.ts | 71 +++++++++++++++++++---------- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/components/RecipeForm/index.tsx b/src/components/RecipeForm/index.tsx index d46996af..e35e8fe5 100644 --- a/src/components/RecipeForm/index.tsx +++ b/src/components/RecipeForm/index.tsx @@ -1,10 +1,11 @@ -import { Button } from "antd"; +import { Button, Tooltip } from "antd"; import InputSwitch from "../InputSwitch"; import "./style.css"; import { useSelectedRecipeId, useFieldsToDisplay, useIsPacking, + useIsOriginalRecipe, } from "../../state/store"; interface RecipeFormProps { @@ -15,6 +16,7 @@ const RecipeForm = ({ onStartPacking }: RecipeFormProps) => { const recipeId = useSelectedRecipeId(); const fieldsToDisplay = useFieldsToDisplay(); const isPacking = useIsPacking(); + const isOriginalRecipe = useIsOriginalRecipe(); return (
@@ -39,15 +41,21 @@ const RecipeForm = ({ onStartPacking }: RecipeFormProps) => {
)} {recipeId && ( - + + )} ); diff --git a/src/state/store.ts b/src/state/store.ts index 080fb9a9..b64d0d73 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -3,7 +3,10 @@ import { subscribeWithSelector } from "zustand/middleware"; import { isEqual, get as lodashGet } from "lodash-es"; import { PackingResult, RecipeData, RecipeManifest } from "../types"; import { jsonToString } from "../utils/recipeLoader"; -import { getRecipeDataFromFirebase, getRecipesFromFirebase } from "../utils/firebase"; +import { + getRecipeDataFromFirebase, + getRecipesFromFirebase, +} from "../utils/firebase"; import { EMPTY_PACKING_RESULT } from "./constants"; import { applyChangesToNestedObject } from "./utils"; @@ -24,7 +27,11 @@ type Actions = { loadRecipe: (recipeId: string) => Promise; loadAllRecipes: () => Promise; selectRecipe: (recipeId: string) => Promise; - editRecipe: (recipeID: string, path: string, value: string | number) => void; + 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; @@ -73,7 +80,7 @@ export const useRecipeStore = create()( set((s) => ({ recipes: { ...s.recipes, - [recipeId]: rec + [recipeId]: rec, }, })); }, @@ -152,7 +159,6 @@ export const useRecipeStore = create()( }); }, - editRecipe: (recipeId, path, value) => { const rec = get().recipes[recipeId]; if (!rec) return; @@ -171,13 +177,12 @@ export const useRecipeStore = create()( ...state.recipes, [recipeId]: { ...rec, - edits: newEdits + edits: newEdits, }, }, })); }, - getCurrentValue: (path) => { const { selectedRecipeId, recipes } = get(); const rec = recipes[selectedRecipeId]; @@ -186,7 +191,10 @@ export const useRecipeStore = create()( // 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") { + if ( + typeof editedValue === "string" || + typeof editedValue === "number" + ) { return editedValue; } return undefined; @@ -194,7 +202,10 @@ export const useRecipeStore = create()( // Otherwise, fall back to the default recipe const defaultValue = lodashGet(rec.defaultRecipeData, path); - if (typeof defaultValue === "string" || typeof defaultValue === "number") { + if ( + typeof defaultValue === "string" || + typeof defaultValue === "number" + ) { return defaultValue; } @@ -206,7 +217,9 @@ export const useRecipeStore = create()( const rec = recipes[selectedRecipeId]?.defaultRecipeData; if (!rec) return undefined; const v = lodashGet(rec, path); - return (typeof v === "string" || typeof v === "number") ? v : undefined; + return typeof v === "string" || typeof v === "number" + ? v + : undefined; }, startPacking: async (callback) => { @@ -214,7 +227,10 @@ export const useRecipeStore = create()( const input = s.inputOptions[s.selectedRecipeId]; const configId = input?.configId ?? ""; const { defaultRecipeData, edits } = s.recipes[s.selectedRecipeId]; - const recipeObject = applyChangesToNestedObject(defaultRecipeData, edits); + const recipeObject = applyChangesToNestedObject( + defaultRecipeData, + edits + ); if (!recipeObject) return; const recipeString = jsonToString(recipeObject); set({ isPacking: true }); @@ -226,7 +242,7 @@ export const useRecipeStore = create()( }, restoreRecipeDefault: (recipeId) => { - set(state => { + set((state) => { const rec = state.recipes[recipeId]; if (!rec) return {}; return { @@ -240,25 +256,26 @@ export const useRecipeStore = create()( }; }); }, - })) ); - // Basic selectors -export const useSelectedRecipeId = () => useRecipeStore(s => s.selectedRecipeId); +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]?.editableFields); -export const useRecipes = () => useRecipeStore(s => s.recipes) -export const usePackingResults = () => useRecipeStore(s => s.packingResults); +export const useRecipes = () => useRecipeStore((s) => s.recipes); +export const usePackingResults = () => useRecipeStore((s) => s.packingResults); export const useCurrentRecipeObject = () => { const recipe = useCurrentRecipeData(); - return recipe ? applyChangesToNestedObject(recipe.defaultRecipeData, recipe.edits) : undefined; -} + return recipe + ? applyChangesToNestedObject(recipe.defaultRecipeData, recipe.edits) + : undefined; +}; const useCurrentRecipeManifest = () => { const selectedRecipeId = useSelectedRecipeId(); @@ -271,14 +288,12 @@ export const useCurrentRecipeData = () => { const selectedRecipeId = useSelectedRecipeId(); const recipes = useRecipes(); return recipes[selectedRecipeId] || undefined; -} +}; const useCurrentPackingResult = () => { const selectedRecipeId = useSelectedRecipeId(); const packingResults = usePackingResults(); - return ( - packingResults[selectedRecipeId] || EMPTY_PACKING_RESULT - ); + return packingResults[selectedRecipeId] || EMPTY_PACKING_RESULT; }; const useDefaultResultPath = () => { @@ -319,12 +334,18 @@ export const useResultUrl = () => { return path; }; +export const useIsOriginalRecipe = () => { + const recipe = useCurrentRecipeData(); + if (!recipe) return true; + return Object.keys(recipe.edits).length === 0; +}; + // 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 useEditRecipe = () => useRecipeStore(s => s.editRecipe); +export const useEditRecipe = () => useRecipeStore((s) => s.editRecipe); export const useRestoreRecipeDefault = () => useRecipeStore((s) => s.restoreRecipeDefault); export const useStartPacking = () => useRecipeStore((s) => s.startPacking); From 34b4a8950c80e3a49609b09b6f039fdab4d8a786 Mon Sep 17 00:00:00 2001 From: meganrm Date: Thu, 13 Nov 2025 09:25:13 -0800 Subject: [PATCH 9/9] small styling fixes --- src/App.css | 2 +- src/components/RecipeForm/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.css b/src/App.css index dad62694..eeada1b6 100644 --- a/src/App.css +++ b/src/App.css @@ -17,7 +17,7 @@ align-items: center; height: var(--header-height); box-sizing: border-box; - border-bottom: 1px solid; + border-bottom: 1px solid #ddd; } .sider { diff --git a/src/components/RecipeForm/index.tsx b/src/components/RecipeForm/index.tsx index e35e8fe5..3ac7bb04 100644 --- a/src/components/RecipeForm/index.tsx +++ b/src/components/RecipeForm/index.tsx @@ -53,7 +53,7 @@ const RecipeForm = ({ onStartPacking }: RecipeFormProps) => { disabled={isPacking || isOriginalRecipe} style={{ width: "100%" }} > - Re-run + Re-run )}