From 2adcb7d832c617b5d7d1e73a9b67cc6a3372dae2 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sun, 25 Jan 2026 16:29:40 +0100 Subject: [PATCH 1/9] Initial work on state machine. Add plan to gitignore and add dependencies. --- .gitignore | 5 ++++- package-lock.json | 54 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0d25e91..8f4b088 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ coverage/ *.webmanifest # Auto-generated version file -src/version.js \ No newline at end of file +src/version.js + +# Planning files +STATE_MACHINE_PLAN.md \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2ac7e17..a243799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@xstate/react": "^6.0.0", "axios": "^1.9.0", "firebase": "^11.10.0", "framer-motion": "^12.18.1", @@ -19,6 +20,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "react-transition-group": "^4.4.5", + "xstate": "^5.25.1", "ynab": "^2.9.0" }, "devDependencies": { @@ -5039,6 +5041,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xstate/react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.0.0.tgz", + "integrity": "sha512-xXlLpFJxqLhhmecAXclBECgk+B4zYSrDTl8hTfPZBogkn82OHKbm9zJxox3Z/YXoOhAQhKFTRLMYGdlbhc6T9A==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.2", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "xstate": "^5.20.0" + }, + "peerDependenciesMeta": { + "xstate": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -11350,6 +11371,29 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", @@ -13399,6 +13443,16 @@ "dev": true, "license": "MIT" }, + "node_modules/xstate": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.25.1.tgz", + "integrity": "sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7916110..1334001 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@xstate/react": "^6.0.0", "axios": "^1.9.0", "firebase": "^11.10.0", "framer-motion": "^12.18.1", @@ -34,6 +35,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "react-transition-group": "^4.4.5", + "xstate": "^5.25.1", "ynab": "^2.9.0" }, "devDependencies": { From 57a2fad7537655a2124ab07ee0831ccbce4d961b Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sun, 25 Jan 2026 18:22:30 +0100 Subject: [PATCH 2/9] Add parallel state machine with visual test interface for API coordination - Create XState v5 parallel state machine for YNAB/SettleUp API coordination - Implement conditional API targeting with proper guard functions - Add support for all outcome scenarios: success, partial success, error states - Build visual test interface with manual API outcome controls - Add real-time state debugging with context display - Implement smart retry logic (full retry vs targeted retry for partial failures) - Set up proper project structure with /src/machines directory - Add /state-machine route for development testing and validation --- src/StateMachineTest.jsx | 321 ++++++++++++++++++++++++++++ src/machines/apiSyncStateMachine.js | 212 ++++++++++++++++++ src/main.jsx | 2 + src/version.js | 12 +- 4 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 src/StateMachineTest.jsx create mode 100644 src/machines/apiSyncStateMachine.js diff --git a/src/StateMachineTest.jsx b/src/StateMachineTest.jsx new file mode 100644 index 0000000..3666945 --- /dev/null +++ b/src/StateMachineTest.jsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { useMachine } from '@xstate/react'; +import { fromPromise } from 'xstate'; +import { apiSyncStateMachine } from './machines/apiSyncStateMachine'; + +function StateMachineTest() { + const [ynabResolver, setYnabResolver] = React.useState(null); + const [settleupResolver, setSettleupResolver] = React.useState(null); + + // Add debugging + React.useEffect(() => { + console.log('ynabResolver updated:', ynabResolver); + }, [ynabResolver]); + + React.useEffect(() => { + console.log('settleupResolver updated:', settleupResolver); + }, [settleupResolver]); + + // Create machine with proper actor override using .provide() + const machineWithActors = React.useMemo(() => { + return apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => { + console.log('YNAB service started - waiting for user input'); + return new Promise((resolve, reject) => { + setYnabResolver({ resolve, reject }); + }); + }), + submitSettleup: fromPromise(() => { + console.log('SettleUp service started - waiting for user input'); + return new Promise((resolve, reject) => { + setSettleupResolver({ resolve, reject }); + }); + }), + }, + }); + }, [setYnabResolver, setSettleupResolver]); + + const [state, send] = useMachine(machineWithActors); + + // Add state change debugging + React.useEffect(() => { + console.log('State changed:', state.value, 'Context:', state.context); + }, [state]); + + // Helper function to get current state path + const getStatePath = (state) => { + const paths = []; + if (state.matches('idle')) paths.push('idle'); + if (state.matches('success')) paths.push('success'); + if (state.matches('error')) paths.push('error'); + if (state.matches('partialSuccess')) paths.push('partialSuccess'); + + if (state.matches('syncing')) { + paths.push('syncing'); + + // Check YNAB substate + if (state.matches({ syncing: { ynab: 'checking' } })) paths.push('syncing.ynab.checking'); + if (state.matches({ syncing: { ynab: 'skipped' } })) paths.push('syncing.ynab.skipped'); + if (state.matches({ syncing: { ynab: 'submitting' } })) paths.push('syncing.ynab.submitting'); + if (state.matches({ syncing: { ynab: 'success' } })) paths.push('syncing.ynab.success'); + if (state.matches({ syncing: { ynab: 'error' } })) paths.push('syncing.ynab.error'); + + // Check SettleUp substate + if (state.matches({ syncing: { settleup: 'checking' } })) paths.push('syncing.settleup.checking'); + if (state.matches({ syncing: { settleup: 'skipped' } })) paths.push('syncing.settleup.skipped'); + if (state.matches({ syncing: { settleup: 'submitting' } })) paths.push('syncing.settleup.submitting'); + if (state.matches({ syncing: { settleup: 'success' } })) paths.push('syncing.settleup.success'); + if (state.matches({ syncing: { settleup: 'error' } })) paths.push('syncing.settleup.error'); + } + + return paths; + }; + + return ( +
+

State Machine Tester

+ + {/* Current State */} +
+

Current States:

+
    + {getStatePath(state).map(path => ( +
  • {path}
  • + ))} +
+
+ + {/* Context Display */} +
+

Context:

+
{JSON.stringify(state.context, null, 2)}
+
+ + {/* Debug Info */} +
+

Debug Info:

+

YNAB Resolver: {ynabResolver ? 'SET' : 'NULL'}

+

SettleUp Resolver: {settleupResolver ? 'SET' : 'NULL'}

+

Check browser console for detailed logs

+
+ + {/* Start Controls */} +
+

Start Sync:

+
+ + + +
+
+ + {/* API Control Buttons */} +
+

Control API Outcomes:

+ + {/* YNAB Controls */} +
+ YNAB API: + + + {ynabResolver && ⏳ Waiting...} +
+ + {/* SettleUp Controls */} +
+ SettleUp API: + + + {settleupResolver && ⏳ Waiting...} +
+
+ + {/* Final State Controls */} +
+

Final State Actions:

+ + + +
+ + {/* Instructions */} +
+

Instructions:

+
    +
  1. Click one of the "Start Sync" buttons to select which APIs to call
  2. +
  3. When APIs start (⏳ Waiting...), use Success/Fail buttons to control outcomes
  4. +
  5. Watch how the state machine transitions based on your choices
  6. +
  7. Test different combinations: both succeed, both fail, one of each
  8. +
+
+
+ ); +} + +export default StateMachineTest; \ No newline at end of file diff --git a/src/machines/apiSyncStateMachine.js b/src/machines/apiSyncStateMachine.js new file mode 100644 index 0000000..5ca09e0 --- /dev/null +++ b/src/machines/apiSyncStateMachine.js @@ -0,0 +1,212 @@ +import { createMachine, assign, fromPromise } from 'xstate'; + +export const apiSyncStateMachine = createMachine({ + id: 'apiSync', + initial: 'idle', + context: { + targets: { ynab: false, settleup: false }, // Which APIs to call + results: { ynab: null, settleup: null }, // API results + errors: { ynab: null, settleup: null }, // API errors + }, + states: { + idle: { + on: { + START_SYNC: { + target: 'syncing', + actions: assign({ + targets: ({ event }) => event.targets, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + }, + }, + + syncing: { + type: 'parallel', + states: { + ynab: { + initial: 'checking', + states: { + checking: { + always: [ + { + target: 'submitting', + guard: { + type: 'ynabTargeted', + params: ({ context }) => ({ targets: context.targets }) + } + }, + { target: 'skipped' }, + ], + }, + skipped: { + type: 'final', + }, + submitting: { + invoke: { + id: 'submitYnabActor', + src: 'submitYnab', + onDone: { + target: 'success', + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + ynab: event.output, + }), + }), + }, + onError: { + target: 'error', + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + ynab: event.error, + }), + }), + }, + }, + }, + success: { + type: 'final', + }, + error: { + type: 'final', + }, + }, + }, + + settleup: { + initial: 'checking', + states: { + checking: { + always: [ + { + target: 'submitting', + guard: { + type: 'settleupTargeted', + params: ({ context }) => ({ targets: context.targets }) + } + }, + { target: 'skipped' }, + ], + }, + skipped: { + type: 'final', + }, + submitting: { + invoke: { + id: 'submitSettleupActor', + src: 'submitSettleup', + onDone: { + target: 'success', + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + settleup: event.output, + }), + }), + }, + onError: { + target: 'error', + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + settleup: event.error, + }), + }), + }, + }, + }, + success: { + type: 'final', + }, + error: { + type: 'final', + }, + }, + }, + }, + onDone: [ + { + target: 'success', + guard: { + type: 'allTargetsSucceeded', + params: ({ context }) => ({ context }) + } + }, + { + target: 'partialSuccess', + guard: { + type: 'partialTargetsSucceeded', + params: ({ context }) => ({ context }) + } + }, + { target: 'error' }, + ], + }, + + success: { + on: { + RESET: { target: 'idle' }, + }, + }, + + partialSuccess: { + on: { + RETRY_FAILED: { + target: 'syncing', + actions: assign({ + targets: ({ context }) => ({ + ynab: context.targets.ynab && !!context.errors.ynab, + settleup: context.targets.settleup && !!context.errors.settleup, + }), + }), + }, + RESET: { target: 'idle' }, + }, + }, + + error: { + on: { + RETRY: { target: 'syncing' }, + RESET: { target: 'idle' }, + }, + }, + }, +}, { + guards: { + ynabTargeted: ({ context }) => context.targets.ynab, + settleupTargeted: ({ context }) => context.targets.settleup, + allTargetsSucceeded: ({ context }) => { + // An API is "OK" if: not targeted OR (has result AND no error) + const ynabOk = !context.targets.ynab || (!!context.results.ynab && !context.errors.ynab); + const settleupOk = !context.targets.settleup || (!!context.results.settleup && !context.errors.settleup); + return ynabOk && settleupOk; + }, + partialTargetsSucceeded: ({ context }) => { + // Only check APIs that were actually targeted + const targetedApis = []; + const successfulApis = []; + + if (context.targets.ynab) { + targetedApis.push('ynab'); + if (context.results.ynab && !context.errors.ynab) { + successfulApis.push('ynab'); + } + } + + if (context.targets.settleup) { + targetedApis.push('settleup'); + if (context.results.settleup && !context.errors.settleup) { + successfulApis.push('settleup'); + } + } + + // Partial success: multiple targets AND some (but not all) succeeded + return targetedApis.length > 1 && + successfulApis.length > 0 && + successfulApis.length < targetedApis.length; + }, + }, +}); diff --git a/src/main.jsx b/src/main.jsx index 6a00acf..f679e93 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -20,6 +20,7 @@ import { import LoginPage from "./LoginPage.jsx"; import MainFormPage from "./MainFormPage.jsx"; import NotFoundPage from "./NotFoundPage.jsx"; +import StateMachineTest from "./StateMachineTest.jsx"; import "./index.css"; function RequireAuth({ children }) { @@ -81,6 +82,7 @@ function RouterApp() { } /> } /> + } /> { From da5861797cb8014eab9a0d6773486b776b720226 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sun, 25 Jan 2026 18:57:08 +0100 Subject: [PATCH 3/9] Update state machine to store complete formState instead of just targets - Store entire formState object in machine context for transaction integrity - Update all guards to check formState.target.ynab/settleup properties - Modify RETRY_FAILED action to work with formState structure - Update StateMachineTest to create realistic mock formState objects - Prepare for API service integration with complete transaction data This ensures the state machine captures a complete snapshot of transaction data when sync starts, preventing inconsistencies if form state changes during API calls. --- src/StateMachineTest.jsx | 37 ++++++++++++++++++++++++++--- src/machines/apiSyncStateMachine.js | 29 ++++++++++++---------- src/version.js | 4 ++-- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/StateMachineTest.jsx b/src/StateMachineTest.jsx index 3666945..5884c75 100644 --- a/src/StateMachineTest.jsx +++ b/src/StateMachineTest.jsx @@ -7,6 +7,28 @@ function StateMachineTest() { const [ynabResolver, setYnabResolver] = React.useState(null); const [settleupResolver, setSettleupResolver] = React.useState(null); + // Create mock formState objects for testing + const createMockFormState = (target) => ({ + amountMilliunits: 25000, // $25.00 + description: "Test Transaction", + target, + account: { bourso: true, swile: false }, + payee: "Test Merchant", + payeeId: "test-payee-id", + category: "Groceries", + categoryId: "test-category-id", + date: new Date(), + settleUpCategory: "Food", + settleUpGroups: null, + settleUpGroup: null, + settleUpPayerId: "", + settleUpMembers: [], + settleUpCurrency: "EUR", + swileMilliunits: 25000, + showAccounts: false, + showDetails: false, + }); + // Add debugging React.useEffect(() => { console.log('ynabResolver updated:', ynabResolver); @@ -105,7 +127,10 @@ function StateMachineTest() {

Start Sync:

+ {/* API Sync Stepper */} + + {/* Context Display */}

Context:

diff --git a/src/components/ApiSyncStepper.jsx b/src/components/ApiSyncStepper.jsx new file mode 100644 index 0000000..4205214 --- /dev/null +++ b/src/components/ApiSyncStepper.jsx @@ -0,0 +1,397 @@ +import React from 'react'; +import { MdCheck, MdClose, MdCircle, MdMoreHoriz } from 'react-icons/md'; + +const ApiSyncStepper = ({ state, context }) => { + const currentState = state?.value || 'idle'; + const formState = context?.formState; + const results = context?.results || {}; + const errors = context?.errors || {}; + + // Simplified step state determination + const getStepState = (stepName) => { + switch (stepName) { + case 'entry': + return currentState === 'idle' ? 'current' : 'success'; + + case 'ynab': + if (!formState?.target?.ynab) return 'disabled'; + // Check if we're in any syncing state + if (currentState === 'syncing' || (typeof currentState === 'object' && currentState.syncing)) { + // Try to get nested state from different possible structures + const ynabState = state?.value?.syncing?.ynab || + (state?.value && typeof state.value === 'object' ? state.value.syncing?.ynab : null); + if (ynabState === 'submitting') return 'current'; + if (ynabState === 'success') return 'success'; + if (ynabState === 'error') return 'error'; + // If YNAB is targeted and we're syncing, show as current + return 'current'; + } + if (results.ynab && !errors.ynab) return 'success'; + if (errors.ynab) return 'error'; + return 'inactive'; + + case 'settleup': + if (!formState?.target?.settleup) return 'disabled'; + // Check if we're in any syncing state + if (currentState === 'syncing' || (typeof currentState === 'object' && currentState.syncing)) { + // Try to get nested state from different possible structures + const settleupState = state?.value?.syncing?.settleup || + (state?.value && typeof state.value === 'object' ? state.value.syncing?.settleup : null); + if (settleupState === 'submitting') return 'current'; + if (settleupState === 'success') return 'success'; + if (settleupState === 'error') return 'error'; + // If SettleUp is targeted and we're syncing, show as current + return 'current'; + } + if (results.settleup && !errors.settleup) return 'success'; + if (errors.settleup) return 'error'; + return 'inactive'; + + case 'complete': + return ['success', 'error', 'partialSuccess'].includes(currentState) + ? currentState === 'success' ? 'success' : currentState === 'error' ? 'error' : 'inactive' + : 'inactive'; + + default: + return 'inactive'; + } + }; + + const steps = { + entry: getStepState('entry'), + ynab: getStepState('ynab'), + settleup: getStepState('settleup'), + complete: getStepState('complete') + }; + + // Simplified color and line logic + const getColor = (stepState) => { + const colors = { + current: '#3b82f6', // blue + success: '#22c55e', // green + error: '#ef4444', // red + disabled: '#9ca3af', // gray + inactive: '#9ca3af' // same gray as disabled + }; + return colors[stepState] || colors.inactive; + }; + + const getOpacity = (stepState) => { + return stepState === 'inactive' ? 0.6 : 1; + }; + + // New line color logic + const getLineColor = (startStep, endStep) => { + const startColor = getColor(startStep); + const endColor = getColor(endStep); + + // If both have same color, use that color + if (startColor === endColor) { + return startColor; + } + + // Otherwise use end step color + return endColor; + }; + + const getEntryToSplitColor = () => { + // Priority to active branch if only one is active + const ynabActive = steps.ynab !== 'disabled'; + const settleupActive = steps.settleup !== 'disabled'; + + if (ynabActive && !settleupActive) { + return getColor(steps.ynab); + } + if (settleupActive && !ynabActive) { + return getColor(steps.settleup); + } + + // If both active or both inactive, use entry color + return getColor(steps.entry); + }; + + const getBranchToJoinColor = (branchStep) => { + // If branch is disabled/inactive, keep branch color + if (branchStep === 'disabled' || branchStep === 'inactive') { + return getColor(branchStep); + } + + // Otherwise use complete color + return getColor(steps.complete); + }; + + const getJoinToCompleteColor = () => { + // Use complete color + return getColor(steps.complete); + }; + + const StepIcon = ({ stepState, size = 24 }) => { + const iconProps = { size, color: 'white' }; + + switch (stepState) { + case 'success': + return ; + case 'error': + return ; + case 'current': + return ; + default: + return ; + } + }; + + const StepCircle = ({ stepState, label, x, y }) => { + const color = getColor(stepState); + const opacity = stepState === 'disabled' ? 0.5 : getOpacity(stepState); + + return ( + + {/* Pulse animation for current state */} + {stepState === 'current' && ( + + + + + )} + + {/* Main circle */} + + + {/* Icon using foreignObject to embed React component */} + +
+ +
+
+ + {/* Label */} + + {label} + +
+ ); + }; + + // Helper function to create path elements + const createPath = (d, stroke, strokeWidth = 3) => ( + + ); + + const createLine = (x1, y1, x2, y2, stroke, strokeWidth = 3) => ( + + ); + + // SVG layout configuration + const layout = { + width: 800, + height: 280, + entryX: 100, + splitX: 220, + branchX: 400, + joinX: 580, + completeX: 700, + centerY: 140, + ynabY: 80, + settleupY: 200, + cornerRadius: 15 + }; + + // Calculate all line colors using new logic + const entryToSplitColor = getEntryToSplitColor(); + const ynabBranchColor = getColor(steps.ynab); + const settleupBranchColor = getColor(steps.settleup); + const ynabToJoinColor = getBranchToJoinColor(steps.ynab); + const settleupToJoinColor = getBranchToJoinColor(steps.settleup); + const joinToCompleteColor = getJoinToCompleteColor(); + + // Extract error information + const errorList = []; + ['ynab', 'settleup'].forEach(api => { + const error = context?.errors?.[api]; + if (error) { + errorList.push({ + api: api === 'ynab' ? 'YNAB' : 'SettleUp', + message: typeof error === 'string' ? error : error.message || 'Unknown error' + }); + } + }); + + return ( +
+
+

API Sync Progress

+ + + {/* Main horizontal line from Entry to split */} + {createLine( + layout.entryX + 20, + layout.centerY, + layout.splitX - layout.cornerRadius, + layout.centerY, + entryToSplitColor + )} + + {/* YNAB branch */} + + {/* Split to YNAB - use YNAB color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY - layout.cornerRadius}`, + ynabBranchColor + )} + {createLine( + layout.splitX, + layout.centerY - layout.cornerRadius, + layout.splitX, + layout.ynabY + layout.cornerRadius, + ynabBranchColor + )} + {createPath( + `M ${layout.splitX} ${layout.ynabY + layout.cornerRadius} + Q ${layout.splitX} ${layout.ynabY}, ${layout.splitX + layout.cornerRadius} ${layout.ynabY}`, + ynabBranchColor + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.ynabY, + layout.branchX - 20, + layout.ynabY, + ynabBranchColor + )} + + {/* YNAB to join - use branch-to-join color */} + {createLine( + layout.branchX + 20, + layout.ynabY, + layout.joinX - layout.cornerRadius, + layout.ynabY, + ynabToJoinColor + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.ynabY} + Q ${layout.joinX} ${layout.ynabY}, ${layout.joinX} ${layout.ynabY + layout.cornerRadius}`, + ynabToJoinColor + )} + {createLine( + layout.joinX, + layout.ynabY + layout.cornerRadius, + layout.joinX, + layout.centerY - layout.cornerRadius, + ynabToJoinColor + )} + {/* Corner from YNAB vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY - layout.cornerRadius} + Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, + ynabToJoinColor + )} + + + {/* SettleUp branch */} + + {/* Split to SettleUp - use SettleUp color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY + layout.cornerRadius}`, + settleupBranchColor + )} + {createLine( + layout.splitX, + layout.centerY + layout.cornerRadius, + layout.splitX, + layout.settleupY - layout.cornerRadius, + settleupBranchColor + )} + {createPath( + `M ${layout.splitX} ${layout.settleupY - layout.cornerRadius} + Q ${layout.splitX} ${layout.settleupY}, ${layout.splitX + layout.cornerRadius} ${layout.settleupY}`, + settleupBranchColor + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.settleupY, + layout.branchX - 20, + layout.settleupY, + settleupBranchColor + )} + + {/* SettleUp to join - use branch-to-join color */} + {createLine( + layout.branchX + 20, + layout.settleupY, + layout.joinX - layout.cornerRadius, + layout.settleupY, + settleupToJoinColor + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.settleupY} + Q ${layout.joinX} ${layout.settleupY}, ${layout.joinX} ${layout.settleupY - layout.cornerRadius}`, + settleupToJoinColor + )} + {createLine( + layout.joinX, + layout.settleupY - layout.cornerRadius, + layout.joinX, + layout.centerY + layout.cornerRadius, + settleupToJoinColor + )} + {/* Corner from SettleUp vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY + layout.cornerRadius} + Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, + settleupToJoinColor + )} + + + {/* Final line to complete */} + {createLine( + layout.joinX + layout.cornerRadius, + layout.centerY, + layout.completeX - 20, + layout.centerY, + joinToCompleteColor + )} + + {/* Step circles */} + + + + + +
+ + {/* Error Display */} + {errorList.length > 0 && ( +
+

Errors

+ {errorList.map((error, idx) => ( +
+ {error.api}: {error.message} +
+ ))} +
+ )} +
+ ); +}; + +export default ApiSyncStepper; \ No newline at end of file diff --git a/src/version.js b/src/version.js index 0e456c3..dbb949e 100644 --- a/src/version.js +++ b/src/version.js @@ -4,9 +4,9 @@ export const VERSION_INFO = { "tag": "1.2.0", "branch": "87-implement-state-machine-with-stepper-visualization-for-api-call-coordination-ynab-settleup", - "commit": "da58617", + "commit": "db9640f", "isDirty": true, - "buildTime": "2026-01-25T18:12:23.474Z", + "buildTime": "2026-01-25T19:41:23.714Z", "version": "1.2.0", "shortVersion": "1.2.0" }; From 47ab32eebd4dc14fdea80a2e055d625378e1e2cb Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sun, 25 Jan 2026 21:21:42 +0100 Subject: [PATCH 6/9] Linter and Prettier fix --- src/StateMachineTest.jsx | 429 ++++++++++------- src/components/ApiSyncStepper.jsx | 385 ++++++++++------ src/machines/apiSyncStateMachine.js | 432 ++++++++--------- src/machines/apiSyncStateMachine.test.js | 560 ++++++++++++++--------- 4 files changed, 1070 insertions(+), 736 deletions(-) diff --git a/src/StateMachineTest.jsx b/src/StateMachineTest.jsx index b27a111..7b22a97 100644 --- a/src/StateMachineTest.jsx +++ b/src/StateMachineTest.jsx @@ -1,13 +1,14 @@ -import React from 'react'; -import { useMachine } from '@xstate/react'; -import { fromPromise } from 'xstate'; -import { apiSyncStateMachine } from './machines/apiSyncStateMachine'; -import ApiSyncStepper from './components/ApiSyncStepper'; +import { useMachine } from "@xstate/react"; +import React from "react"; +import { fromPromise } from "xstate"; + +import ApiSyncStepper from "./components/ApiSyncStepper.jsx"; +import { apiSyncStateMachine } from "./machines/apiSyncStateMachine"; function StateMachineTest() { const [ynabResolver, setYnabResolver] = React.useState(null); const [settleupResolver, setSettleupResolver] = React.useState(null); - + // Create mock formState objects for testing const createMockFormState = (target) => ({ amountMilliunits: 25000, // $25.00 @@ -29,28 +30,28 @@ function StateMachineTest() { showAccounts: false, showDetails: false, }); - + // Add debugging React.useEffect(() => { - console.log('ynabResolver updated:', ynabResolver); + console.log("ynabResolver updated:", ynabResolver); }, [ynabResolver]); - + React.useEffect(() => { - console.log('settleupResolver updated:', settleupResolver); + console.log("settleupResolver updated:", settleupResolver); }, [settleupResolver]); - + // Create machine with proper actor override using .provide() const machineWithActors = React.useMemo(() => { return apiSyncStateMachine.provide({ actors: { submitYnab: fromPromise(() => { - console.log('YNAB service started - waiting for user input'); + console.log("YNAB service started - waiting for user input"); return new Promise((resolve, reject) => { setYnabResolver({ resolve, reject }); }); }), submitSettleup: fromPromise(() => { - console.log('SettleUp service started - waiting for user input'); + console.log("SettleUp service started - waiting for user input"); return new Promise((resolve, reject) => { setSettleupResolver({ resolve, reject }); }); @@ -60,51 +61,65 @@ function StateMachineTest() { }, [setYnabResolver, setSettleupResolver]); const [state, send] = useMachine(machineWithActors); - + // Add state change debugging React.useEffect(() => { - console.log('State changed:', state.value, 'Context:', state.context); + console.log("State changed:", state.value, "Context:", state.context); }, [state]); // Helper function to get current state path const getStatePath = (state) => { const paths = []; - if (state.matches('idle')) paths.push('idle'); - if (state.matches('success')) paths.push('success'); - if (state.matches('error')) paths.push('error'); - if (state.matches('partialSuccess')) paths.push('partialSuccess'); - - if (state.matches('syncing')) { - paths.push('syncing'); - + if (state.matches("idle")) paths.push("idle"); + if (state.matches("success")) paths.push("success"); + if (state.matches("error")) paths.push("error"); + if (state.matches("partialSuccess")) paths.push("partialSuccess"); + + if (state.matches("syncing")) { + paths.push("syncing"); + // Check YNAB substate - if (state.matches({ syncing: { ynab: 'checking' } })) paths.push('syncing.ynab.checking'); - if (state.matches({ syncing: { ynab: 'skipped' } })) paths.push('syncing.ynab.skipped'); - if (state.matches({ syncing: { ynab: 'submitting' } })) paths.push('syncing.ynab.submitting'); - if (state.matches({ syncing: { ynab: 'success' } })) paths.push('syncing.ynab.success'); - if (state.matches({ syncing: { ynab: 'error' } })) paths.push('syncing.ynab.error'); - - // Check SettleUp substate - if (state.matches({ syncing: { settleup: 'checking' } })) paths.push('syncing.settleup.checking'); - if (state.matches({ syncing: { settleup: 'skipped' } })) paths.push('syncing.settleup.skipped'); - if (state.matches({ syncing: { settleup: 'submitting' } })) paths.push('syncing.settleup.submitting'); - if (state.matches({ syncing: { settleup: 'success' } })) paths.push('syncing.settleup.success'); - if (state.matches({ syncing: { settleup: 'error' } })) paths.push('syncing.settleup.error'); + if (state.matches({ syncing: { ynab: "checking" } })) + paths.push("syncing.ynab.checking"); + if (state.matches({ syncing: { ynab: "skipped" } })) + paths.push("syncing.ynab.skipped"); + if (state.matches({ syncing: { ynab: "submitting" } })) + paths.push("syncing.ynab.submitting"); + if (state.matches({ syncing: { ynab: "success" } })) + paths.push("syncing.ynab.success"); + if (state.matches({ syncing: { ynab: "error" } })) + paths.push("syncing.ynab.error"); + + // Check SettleUp substate + if (state.matches({ syncing: { settleup: "checking" } })) + paths.push("syncing.settleup.checking"); + if (state.matches({ syncing: { settleup: "skipped" } })) + paths.push("syncing.settleup.skipped"); + if (state.matches({ syncing: { settleup: "submitting" } })) + paths.push("syncing.settleup.submitting"); + if (state.matches({ syncing: { settleup: "success" } })) + paths.push("syncing.settleup.success"); + if (state.matches({ syncing: { settleup: "error" } })) + paths.push("syncing.settleup.error"); } - + return paths; }; return ( -
+

State Machine Tester

- + {/* Current State */} -
+

Current States:

    - {getStatePath(state).map(path => ( -
  • {path}
  • + {getStatePath(state).map((path) => ( +
  • + {path} +
  • ))}
@@ -113,79 +128,94 @@ function StateMachineTest() { {/* Context Display */} -
+

Context:

{JSON.stringify(state.context, null, 2)}
{/* Debug Info */} -
+

Debug Info:

-

YNAB Resolver: {ynabResolver ? 'SET' : 'NULL'}

-

SettleUp Resolver: {settleupResolver ? 'SET' : 'NULL'}

+

YNAB Resolver: {ynabResolver ? "SET" : "NULL"}

+

SettleUp Resolver: {settleupResolver ? "SET" : "NULL"}

Check browser console for detailed logs

{/* Start Controls */} -
+

Start Sync:

-
- - -
{/* API Control Buttons */} -
+

Control API Outcomes:

- + {/* YNAB Controls */} -
+
YNAB API: - - - {ynabResolver && ⏳ Waiting...} + {ynabResolver && ( + + ⏳ Waiting... + + )}
{/* SettleUp Controls */}
SettleUp API: - - - {settleupResolver && ⏳ Waiting...} + {settleupResolver && ( + + ⏳ Waiting... + + )}
{/* Final State Controls */} -
+

Final State Actions:

- - -
{/* Instructions */} -
+

Instructions:

    -
  1. Click one of the "Start Sync" buttons to select which APIs to call
  2. -
  3. When APIs start (⏳ Waiting...), use Success/Fail buttons to control outcomes
  4. +
  5. + Click one of the "Start Sync" buttons to select which APIs + to call +
  6. +
  7. + When APIs start (⏳ Waiting...), use + "Success"/"Fail" buttons to control outcomes +
  8. Watch how the state machine transitions based on your choices
  9. -
  10. Test different combinations: both succeed, both fail, one of each
  11. +
  12. + Test different combinations: both succeed, both fail, one of each +
); } -export default StateMachineTest; \ No newline at end of file +export default StateMachineTest; diff --git a/src/components/ApiSyncStepper.jsx b/src/components/ApiSyncStepper.jsx index 4205214..95795ec 100644 --- a/src/components/ApiSyncStepper.jsx +++ b/src/components/ApiSyncStepper.jsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { MdCheck, MdClose, MdCircle, MdMoreHoriz } from 'react-icons/md'; +import PropTypes from "prop-types"; +import React from "react"; +import { MdCheck, MdClose, MdCircle, MdMoreHoriz } from "react-icons/md"; const ApiSyncStepper = ({ state, context }) => { - const currentState = state?.value || 'idle'; + const currentState = state?.value || "idle"; const formState = context?.formState; const results = context?.results || {}; const errors = context?.errors || {}; @@ -10,144 +11,165 @@ const ApiSyncStepper = ({ state, context }) => { // Simplified step state determination const getStepState = (stepName) => { switch (stepName) { - case 'entry': - return currentState === 'idle' ? 'current' : 'success'; - - case 'ynab': - if (!formState?.target?.ynab) return 'disabled'; + case "entry": + return currentState === "idle" ? "current" : "success"; + + case "ynab": + if (!formState?.target?.ynab) return "disabled"; // Check if we're in any syncing state - if (currentState === 'syncing' || (typeof currentState === 'object' && currentState.syncing)) { + if ( + currentState === "syncing" || + (typeof currentState === "object" && currentState.syncing) + ) { // Try to get nested state from different possible structures - const ynabState = state?.value?.syncing?.ynab || - (state?.value && typeof state.value === 'object' ? state.value.syncing?.ynab : null); - if (ynabState === 'submitting') return 'current'; - if (ynabState === 'success') return 'success'; - if (ynabState === 'error') return 'error'; + const ynabState = + state?.value?.syncing?.ynab || + (state?.value && typeof state.value === "object" + ? state.value.syncing?.ynab + : null); + if (ynabState === "submitting") return "current"; + if (ynabState === "success") return "success"; + if (ynabState === "error") return "error"; // If YNAB is targeted and we're syncing, show as current - return 'current'; + return "current"; } - if (results.ynab && !errors.ynab) return 'success'; - if (errors.ynab) return 'error'; - return 'inactive'; - - case 'settleup': - if (!formState?.target?.settleup) return 'disabled'; + if (results.ynab && !errors.ynab) return "success"; + if (errors.ynab) return "error"; + return "inactive"; + + case "settleup": + if (!formState?.target?.settleup) return "disabled"; // Check if we're in any syncing state - if (currentState === 'syncing' || (typeof currentState === 'object' && currentState.syncing)) { + if ( + currentState === "syncing" || + (typeof currentState === "object" && currentState.syncing) + ) { // Try to get nested state from different possible structures - const settleupState = state?.value?.syncing?.settleup || - (state?.value && typeof state.value === 'object' ? state.value.syncing?.settleup : null); - if (settleupState === 'submitting') return 'current'; - if (settleupState === 'success') return 'success'; - if (settleupState === 'error') return 'error'; + const settleupState = + state?.value?.syncing?.settleup || + (state?.value && typeof state.value === "object" + ? state.value.syncing?.settleup + : null); + if (settleupState === "submitting") return "current"; + if (settleupState === "success") return "success"; + if (settleupState === "error") return "error"; // If SettleUp is targeted and we're syncing, show as current - return 'current'; + return "current"; } - if (results.settleup && !errors.settleup) return 'success'; - if (errors.settleup) return 'error'; - return 'inactive'; - - case 'complete': - return ['success', 'error', 'partialSuccess'].includes(currentState) - ? currentState === 'success' ? 'success' : currentState === 'error' ? 'error' : 'inactive' - : 'inactive'; - + if (results.settleup && !errors.settleup) return "success"; + if (errors.settleup) return "error"; + return "inactive"; + + case "complete": + return ["success", "error", "partialSuccess"].includes(currentState) + ? currentState === "success" + ? "success" + : currentState === "error" + ? "error" + : "inactive" + : "inactive"; + default: - return 'inactive'; + return "inactive"; } }; const steps = { - entry: getStepState('entry'), - ynab: getStepState('ynab'), - settleup: getStepState('settleup'), - complete: getStepState('complete') + entry: getStepState("entry"), + ynab: getStepState("ynab"), + settleup: getStepState("settleup"), + complete: getStepState("complete"), }; // Simplified color and line logic const getColor = (stepState) => { const colors = { - current: '#3b82f6', // blue - success: '#22c55e', // green - error: '#ef4444', // red - disabled: '#9ca3af', // gray - inactive: '#9ca3af' // same gray as disabled + current: "#3b82f6", // blue + success: "#22c55e", // green + error: "#ef4444", // red + disabled: "#9ca3af", // gray + inactive: "#9ca3af", // same gray as disabled }; return colors[stepState] || colors.inactive; }; const getOpacity = (stepState) => { - return stepState === 'inactive' ? 0.6 : 1; + return stepState === "inactive" ? 0.6 : 1; }; // New line color logic const getLineColor = (startStep, endStep) => { const startColor = getColor(startStep); const endColor = getColor(endStep); - + // If both have same color, use that color if (startColor === endColor) { return startColor; } - + // Otherwise use end step color return endColor; }; - + const getEntryToSplitColor = () => { // Priority to active branch if only one is active - const ynabActive = steps.ynab !== 'disabled'; - const settleupActive = steps.settleup !== 'disabled'; - + const ynabActive = steps.ynab !== "disabled"; + const settleupActive = steps.settleup !== "disabled"; + if (ynabActive && !settleupActive) { return getColor(steps.ynab); } if (settleupActive && !ynabActive) { return getColor(steps.settleup); } - + // If both active or both inactive, use entry color return getColor(steps.entry); }; - + const getBranchToJoinColor = (branchStep) => { // If branch is disabled/inactive, keep branch color - if (branchStep === 'disabled' || branchStep === 'inactive') { + if (branchStep === "disabled" || branchStep === "inactive") { return getColor(branchStep); } - + // Otherwise use complete color return getColor(steps.complete); }; - + const getJoinToCompleteColor = () => { // Use complete color return getColor(steps.complete); }; const StepIcon = ({ stepState, size = 24 }) => { - const iconProps = { size, color: 'white' }; - + const iconProps = { size, color: "white" }; + switch (stepState) { - case 'success': + case "success": return ; - case 'error': + case "error": return ; - case 'current': + case "current": return ; default: return ; } }; + StepIcon.propTypes = { + stepState: PropTypes.string, + size: PropTypes.number, + }; + const StepCircle = ({ stepState, label, x, y }) => { const color = getColor(stepState); - const opacity = stepState === 'disabled' ? 0.5 : getOpacity(stepState); - + const opacity = stepState === "disabled" ? 0.5 : getOpacity(stepState); + return ( {/* Pulse animation for current state */} - {stepState === 'current' && ( + {stepState === "current" && ( { /> )} - + {/* Main circle */} - - + + {/* Icon using foreignObject to embed React component */}
- + {/* Label */} { ); }; + StepCircle.propTypes = { + stepState: PropTypes.string, + label: PropTypes.string, + x: PropTypes.number, + y: PropTypes.number, + }; + // Helper function to create path elements const createPath = (d, stroke, strokeWidth = 3) => ( ); const createLine = (x1, y1, x2, y2, stroke, strokeWidth = 3) => ( - + ); // SVG layout configuration @@ -212,7 +255,7 @@ const ApiSyncStepper = ({ state, context }) => { centerY: 140, ynabY: 80, settleupY: 200, - cornerRadius: 15 + cornerRadius: 15, }; // Calculate all line colors using new logic @@ -225,12 +268,13 @@ const ApiSyncStepper = ({ state, context }) => { // Extract error information const errorList = []; - ['ynab', 'settleup'].forEach(api => { + ["ynab", "settleup"].forEach((api) => { const error = context?.errors?.[api]; if (error) { errorList.push({ - api: api === 'ynab' ? 'YNAB' : 'SettleUp', - message: typeof error === 'string' ? error : error.message || 'Unknown error' + api: api === "ynab" ? "YNAB" : "SettleUp", + message: + typeof error === "string" ? error : error.message || "Unknown error", }); } }); @@ -238,144 +282,173 @@ const ApiSyncStepper = ({ state, context }) => { return (
-

API Sync Progress

- - +

+ API Sync Progress +

+ + {/* Main horizontal line from Entry to split */} {createLine( - layout.entryX + 20, - layout.centerY, - layout.splitX - layout.cornerRadius, - layout.centerY, - entryToSplitColor + layout.entryX + 20, + layout.centerY, + layout.splitX - layout.cornerRadius, + layout.centerY, + entryToSplitColor, )} {/* YNAB branch */} - + {/* Split to YNAB - use YNAB color for the corner */} {createPath( `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY - layout.cornerRadius}`, - ynabBranchColor + ynabBranchColor, )} {createLine( - layout.splitX, - layout.centerY - layout.cornerRadius, - layout.splitX, - layout.ynabY + layout.cornerRadius, - ynabBranchColor + layout.splitX, + layout.centerY - layout.cornerRadius, + layout.splitX, + layout.ynabY + layout.cornerRadius, + ynabBranchColor, )} {createPath( `M ${layout.splitX} ${layout.ynabY + layout.cornerRadius} Q ${layout.splitX} ${layout.ynabY}, ${layout.splitX + layout.cornerRadius} ${layout.ynabY}`, - ynabBranchColor + ynabBranchColor, )} {createLine( - layout.splitX + layout.cornerRadius, - layout.ynabY, - layout.branchX - 20, - layout.ynabY, - ynabBranchColor + layout.splitX + layout.cornerRadius, + layout.ynabY, + layout.branchX - 20, + layout.ynabY, + ynabBranchColor, )} {/* YNAB to join - use branch-to-join color */} {createLine( - layout.branchX + 20, - layout.ynabY, - layout.joinX - layout.cornerRadius, - layout.ynabY, - ynabToJoinColor + layout.branchX + 20, + layout.ynabY, + layout.joinX - layout.cornerRadius, + layout.ynabY, + ynabToJoinColor, )} {createPath( `M ${layout.joinX - layout.cornerRadius} ${layout.ynabY} Q ${layout.joinX} ${layout.ynabY}, ${layout.joinX} ${layout.ynabY + layout.cornerRadius}`, - ynabToJoinColor + ynabToJoinColor, )} {createLine( - layout.joinX, - layout.ynabY + layout.cornerRadius, - layout.joinX, - layout.centerY - layout.cornerRadius, - ynabToJoinColor + layout.joinX, + layout.ynabY + layout.cornerRadius, + layout.joinX, + layout.centerY - layout.cornerRadius, + ynabToJoinColor, )} {/* Corner from YNAB vertical to center horizontal */} {createPath( `M ${layout.joinX} ${layout.centerY - layout.cornerRadius} Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, - ynabToJoinColor + ynabToJoinColor, )} {/* SettleUp branch */} - + {/* Split to SettleUp - use SettleUp color for the corner */} {createPath( `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY + layout.cornerRadius}`, - settleupBranchColor + settleupBranchColor, )} {createLine( - layout.splitX, - layout.centerY + layout.cornerRadius, - layout.splitX, - layout.settleupY - layout.cornerRadius, - settleupBranchColor + layout.splitX, + layout.centerY + layout.cornerRadius, + layout.splitX, + layout.settleupY - layout.cornerRadius, + settleupBranchColor, )} {createPath( `M ${layout.splitX} ${layout.settleupY - layout.cornerRadius} Q ${layout.splitX} ${layout.settleupY}, ${layout.splitX + layout.cornerRadius} ${layout.settleupY}`, - settleupBranchColor + settleupBranchColor, )} {createLine( - layout.splitX + layout.cornerRadius, - layout.settleupY, - layout.branchX - 20, - layout.settleupY, - settleupBranchColor + layout.splitX + layout.cornerRadius, + layout.settleupY, + layout.branchX - 20, + layout.settleupY, + settleupBranchColor, )} {/* SettleUp to join - use branch-to-join color */} {createLine( - layout.branchX + 20, - layout.settleupY, - layout.joinX - layout.cornerRadius, - layout.settleupY, - settleupToJoinColor + layout.branchX + 20, + layout.settleupY, + layout.joinX - layout.cornerRadius, + layout.settleupY, + settleupToJoinColor, )} {createPath( `M ${layout.joinX - layout.cornerRadius} ${layout.settleupY} Q ${layout.joinX} ${layout.settleupY}, ${layout.joinX} ${layout.settleupY - layout.cornerRadius}`, - settleupToJoinColor + settleupToJoinColor, )} {createLine( - layout.joinX, - layout.settleupY - layout.cornerRadius, - layout.joinX, - layout.centerY + layout.cornerRadius, - settleupToJoinColor + layout.joinX, + layout.settleupY - layout.cornerRadius, + layout.joinX, + layout.centerY + layout.cornerRadius, + settleupToJoinColor, )} {/* Corner from SettleUp vertical to center horizontal */} {createPath( `M ${layout.joinX} ${layout.centerY + layout.cornerRadius} Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, - settleupToJoinColor + settleupToJoinColor, )} {/* Final line to complete */} {createLine( - layout.joinX + layout.cornerRadius, - layout.centerY, - layout.completeX - 20, - layout.centerY, - joinToCompleteColor + layout.joinX + layout.cornerRadius, + layout.centerY, + layout.completeX - 20, + layout.centerY, + joinToCompleteColor, )} {/* Step circles */} - - - - + + + +
@@ -394,4 +467,28 @@ const ApiSyncStepper = ({ state, context }) => { ); }; -export default ApiSyncStepper; \ No newline at end of file +ApiSyncStepper.propTypes = { + state: PropTypes.shape({ + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + syncing: PropTypes.shape({ + ynab: PropTypes.string, + settleup: PropTypes.string, + }), + }), + ]), + }), + context: PropTypes.shape({ + formState: PropTypes.shape({ + target: PropTypes.shape({ + ynab: PropTypes.bool, + settleup: PropTypes.bool, + }), + }), + results: PropTypes.object, + errors: PropTypes.object, + }), +}; + +export default ApiSyncStepper; diff --git a/src/machines/apiSyncStateMachine.js b/src/machines/apiSyncStateMachine.js index 972d296..cae1110 100644 --- a/src/machines/apiSyncStateMachine.js +++ b/src/machines/apiSyncStateMachine.js @@ -1,244 +1,260 @@ -import { createMachine, assign, fromPromise } from 'xstate'; +import { createMachine, assign, fromPromise } from "xstate"; -export const apiSyncStateMachine = createMachine({ - id: 'apiSync', - initial: 'idle', - context: { - formState: null, // Complete transaction data - results: { ynab: null, settleup: null }, // API results - errors: { ynab: null, settleup: null }, // API errors - }, - states: { - idle: { - on: { - START_SYNC: { - target: 'syncing', - actions: assign({ - formState: ({ event }) => event.formState, - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), +export const apiSyncStateMachine = createMachine( + { + id: "apiSync", + initial: "idle", + context: { + formState: null, // Complete transaction data + results: { ynab: null, settleup: null }, // API results + errors: { ynab: null, settleup: null }, // API errors + }, + states: { + idle: { + on: { + START_SYNC: { + target: "syncing", + actions: assign({ + formState: ({ event }) => event.formState, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, }, }, - }, - syncing: { - type: 'parallel', - states: { - ynab: { - initial: 'checking', - states: { - checking: { - always: [ - { - target: 'submitting', - guard: { - type: 'ynabTargeted', - params: ({ context }) => ({ formState: context.formState }) - } - }, - { target: 'skipped' }, - ], - }, - skipped: { - type: 'final', - }, - submitting: { - invoke: { - id: 'submitYnabActor', - src: 'submitYnab', - onDone: { - target: 'success', - actions: assign({ - results: ({ context, event }) => ({ - ...context.results, - ynab: event.output, + syncing: { + type: "parallel", + states: { + ynab: { + initial: "checking", + states: { + checking: { + always: [ + { + target: "submitting", + guard: { + type: "ynabTargeted", + params: ({ context }) => ({ + formState: context.formState, + }), + }, + }, + { target: "skipped" }, + ], + }, + skipped: { + type: "final", + }, + submitting: { + invoke: { + id: "submitYnabActor", + src: "submitYnab", + onDone: { + target: "success", + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + ynab: event.output, + }), }), - }), - }, - onError: { - target: 'error', - actions: assign({ - errors: ({ context, event }) => ({ - ...context.errors, - ynab: event.error, + }, + onError: { + target: "error", + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + ynab: event.error, + }), }), - }), + }, }, }, - }, - success: { - type: 'final', - }, - error: { - type: 'final', + success: { + type: "final", + }, + error: { + type: "final", + }, }, }, - }, - settleup: { - initial: 'checking', - states: { - checking: { - always: [ - { - target: 'submitting', - guard: { - type: 'settleupTargeted', - params: ({ context }) => ({ formState: context.formState }) - } - }, - { target: 'skipped' }, - ], - }, - skipped: { - type: 'final', - }, - submitting: { - invoke: { - id: 'submitSettleupActor', - src: 'submitSettleup', - onDone: { - target: 'success', - actions: assign({ - results: ({ context, event }) => ({ - ...context.results, - settleup: event.output, + settleup: { + initial: "checking", + states: { + checking: { + always: [ + { + target: "submitting", + guard: { + type: "settleupTargeted", + params: ({ context }) => ({ + formState: context.formState, + }), + }, + }, + { target: "skipped" }, + ], + }, + skipped: { + type: "final", + }, + submitting: { + invoke: { + id: "submitSettleupActor", + src: "submitSettleup", + onDone: { + target: "success", + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + settleup: event.output, + }), }), - }), - }, - onError: { - target: 'error', - actions: assign({ - errors: ({ context, event }) => ({ - ...context.errors, - settleup: event.error, + }, + onError: { + target: "error", + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + settleup: event.error, + }), }), - }), + }, }, }, + success: { + type: "final", + }, + error: { + type: "final", + }, }, - success: { - type: 'final', + }, + }, + onDone: [ + { + target: "success", + guard: { + type: "allTargetsSucceeded", + params: ({ context }) => ({ context }), }, - error: { - type: 'final', + }, + { + target: "partialSuccess", + guard: { + type: "partialTargetsSucceeded", + params: ({ context }) => ({ context }), }, }, - }, + { target: "error" }, + ], }, - onDone: [ - { - target: 'success', - guard: { - type: 'allTargetsSucceeded', - params: ({ context }) => ({ context }) - } - }, - { - target: 'partialSuccess', - guard: { - type: 'partialTargetsSucceeded', - params: ({ context }) => ({ context }) - } - }, - { target: 'error' }, - ], - }, - success: { - on: { - RESET: { - target: 'idle', - actions: assign({ - formState: null, - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), + success: { + on: { + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, }, }, - }, - partialSuccess: { - on: { - RETRY_FAILED: { - target: 'syncing', - actions: assign({ - formState: ({ context }) => ({ - ...context.formState, - target: { - ynab: context.formState.target.ynab && !!context.errors.ynab, - settleup: context.formState.target.settleup && !!context.errors.settleup, - } + partialSuccess: { + on: { + RETRY_FAILED: { + target: "syncing", + actions: assign({ + formState: ({ context }) => ({ + ...context.formState, + target: { + ynab: context.formState.target.ynab && !!context.errors.ynab, + settleup: + context.formState.target.settleup && + !!context.errors.settleup, + }, + }), + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, }), - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), - }, - RESET: { - target: 'idle', - actions: assign({ - formState: null, - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), + }, + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, }, }, - }, - error: { - on: { - RETRY: { - target: 'syncing', - actions: assign({ - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), - }, - RESET: { - target: 'idle', - actions: assign({ - formState: null, - results: { ynab: null, settleup: null }, - errors: { ynab: null, settleup: null }, - }), + error: { + on: { + RETRY: { + target: "syncing", + actions: assign({ + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, }, }, }, }, -}, { - guards: { - ynabTargeted: ({ context }) => context.formState?.target?.ynab || false, - settleupTargeted: ({ context }) => context.formState?.target?.settleup || false, - allTargetsSucceeded: ({ context }) => { - // An API is "OK" if: not targeted OR (has result AND no error) - const ynabOk = !context.formState?.target?.ynab || (!!context.results.ynab && !context.errors.ynab); - const settleupOk = !context.formState?.target?.settleup || (!!context.results.settleup && !context.errors.settleup); - return ynabOk && settleupOk; - }, - partialTargetsSucceeded: ({ context }) => { - // Only check APIs that were actually targeted - const targetedApis = []; - const successfulApis = []; - - if (context.formState?.target?.ynab) { - targetedApis.push('ynab'); - if (context.results.ynab && !context.errors.ynab) { - successfulApis.push('ynab'); + { + guards: { + ynabTargeted: ({ context }) => context.formState?.target?.ynab || false, + settleupTargeted: ({ context }) => + context.formState?.target?.settleup || false, + allTargetsSucceeded: ({ context }) => { + // An API is "OK" if: not targeted OR (has result AND no error) + const ynabOk = + !context.formState?.target?.ynab || + (!!context.results.ynab && !context.errors.ynab); + const settleupOk = + !context.formState?.target?.settleup || + (!!context.results.settleup && !context.errors.settleup); + return ynabOk && settleupOk; + }, + partialTargetsSucceeded: ({ context }) => { + // Only check APIs that were actually targeted + const targetedApis = []; + const successfulApis = []; + + if (context.formState?.target?.ynab) { + targetedApis.push("ynab"); + if (context.results.ynab && !context.errors.ynab) { + successfulApis.push("ynab"); + } } - } - - if (context.formState?.target?.settleup) { - targetedApis.push('settleup'); - if (context.results.settleup && !context.errors.settleup) { - successfulApis.push('settleup'); + + if (context.formState?.target?.settleup) { + targetedApis.push("settleup"); + if (context.results.settleup && !context.errors.settleup) { + successfulApis.push("settleup"); + } } - } - - // Partial success: multiple targets AND some (but not all) succeeded - return targetedApis.length > 1 && - successfulApis.length > 0 && - successfulApis.length < targetedApis.length; + + // Partial success: multiple targets AND some (but not all) succeeded + return ( + targetedApis.length > 1 && + successfulApis.length > 0 && + successfulApis.length < targetedApis.length + ); + }, }, }, -}); +); diff --git a/src/machines/apiSyncStateMachine.test.js b/src/machines/apiSyncStateMachine.test.js index d9db5ae..4104290 100644 --- a/src/machines/apiSyncStateMachine.test.js +++ b/src/machines/apiSyncStateMachine.test.js @@ -1,36 +1,37 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createActor, fromPromise } from 'xstate'; -import { apiSyncStateMachine } from './apiSyncStateMachine'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createActor, fromPromise } from "xstate"; -describe('apiSyncStateMachine', () => { +import { apiSyncStateMachine } from "./apiSyncStateMachine"; + +describe("apiSyncStateMachine", () => { // Mock formState helper const createMockFormState = (target = { ynab: true, settleup: false }) => ({ amountMilliunits: 25000, - description: 'Test Transaction', + description: "Test Transaction", target, account: { bourso: true, swile: false }, - payee: 'Test Merchant', - payeeId: 'test-payee-id', - category: 'Groceries', - categoryId: 'test-category-id', + payee: "Test Merchant", + payeeId: "test-payee-id", + category: "Groceries", + categoryId: "test-category-id", date: new Date(), - settleUpCategory: 'Food', + settleUpCategory: "Food", settleUpGroups: null, settleUpGroup: null, - settleUpPayerId: '', + settleUpPayerId: "", settleUpMembers: [], - settleUpCurrency: 'EUR', + settleUpCurrency: "EUR", swileMilliunits: 25000, showAccounts: false, showDetails: false, }); - describe('Initial State', () => { - it('should start in idle state with correct initial context', () => { + describe("Initial State", () => { + it("should start in idle state with correct initial context", () => { const actor = createActor(apiSyncStateMachine); actor.start(); - expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().value).toBe("idle"); expect(actor.getSnapshot().context).toEqual({ formState: null, results: { ynab: null, settleup: null }, @@ -41,16 +42,16 @@ describe('apiSyncStateMachine', () => { }); }); - describe('START_SYNC Transitions', () => { - it('should transition to syncing state and store formState', () => { + describe("START_SYNC Transitions", () => { + it("should transition to syncing state and store formState", () => { const actor = createActor(apiSyncStateMachine); actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); - expect(snapshot.matches('syncing')).toBe(true); + expect(snapshot.matches("syncing")).toBe(true); expect(snapshot.context.formState).toEqual(formState); expect(snapshot.context.results).toEqual({ ynab: null, settleup: null }); expect(snapshot.context.errors).toEqual({ ynab: null, settleup: null }); @@ -58,11 +59,13 @@ describe('apiSyncStateMachine', () => { actor.stop(); }); - it('should handle YNAB-only target correctly', () => { + it("should handle YNAB-only target correctly", () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -70,20 +73,22 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); - expect(snapshot.matches({ syncing: { ynab: 'submitting' } })).toBe(true); - expect(snapshot.matches({ syncing: { settleup: 'skipped' } })).toBe(true); + expect(snapshot.matches({ syncing: { ynab: "submitting" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "skipped" } })).toBe(true); actor.stop(); }); - it('should handle SettleUp-only target correctly', () => { + it("should handle SettleUp-only target correctly", () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -91,20 +96,24 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: false, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); - expect(snapshot.matches({ syncing: { ynab: 'skipped' } })).toBe(true); - expect(snapshot.matches({ syncing: { settleup: 'submitting' } })).toBe(true); + expect(snapshot.matches({ syncing: { ynab: "skipped" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "submitting" } })).toBe( + true, + ); actor.stop(); }); - it('should handle both APIs target correctly', () => { + it("should handle both APIs target correctly", () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -112,17 +121,19 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); - expect(snapshot.matches({ syncing: { ynab: 'submitting' } })).toBe(true); - expect(snapshot.matches({ syncing: { settleup: 'submitting' } })).toBe(true); + expect(snapshot.matches({ syncing: { ynab: "submitting" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "submitting" } })).toBe( + true, + ); actor.stop(); }); }); - describe('Guards', () => { + describe("Guards", () => { let actor; beforeEach(() => { @@ -134,41 +145,41 @@ describe('apiSyncStateMachine', () => { actor.stop(); }); - describe('ynabTargeted guard', () => { - it('should return true when YNAB is targeted', () => { + describe("ynabTargeted guard", () => { + it("should return true when YNAB is targeted", () => { const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); expect(snapshot.context.formState.target.ynab).toBe(true); }); - it('should return false when YNAB is not targeted', () => { + it("should return false when YNAB is not targeted", () => { const formState = createMockFormState({ ynab: false, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); expect(snapshot.context.formState.target.ynab).toBe(false); }); - it('should handle null formState gracefully', () => { + it("should handle null formState gracefully", () => { const snapshot = actor.getSnapshot(); expect(snapshot.context.formState?.target?.ynab || false).toBe(false); }); }); - describe('settleupTargeted guard', () => { - it('should return true when SettleUp is targeted', () => { + describe("settleupTargeted guard", () => { + it("should return true when SettleUp is targeted", () => { const formState = createMockFormState({ ynab: false, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); expect(snapshot.context.formState.target.settleup).toBe(true); }); - it('should return false when SettleUp is not targeted', () => { + it("should return false when SettleUp is not targeted", () => { const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); expect(snapshot.context.formState.target.settleup).toBe(false); @@ -176,12 +187,14 @@ describe('apiSyncStateMachine', () => { }); }); - describe('Success Scenarios', () => { - it('should reach success state when all targeted APIs succeed', async () => { + describe("Success Scenarios", () => { + it("should reach success state when all targeted APIs succeed", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -189,29 +202,38 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for the machine to settle - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('success') || snapshot.matches('error') || snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("success") || + snapshot.matches("error") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('success')).toBe(true); - expect(finalSnapshot.context.results.ynab).toBe('YNAB Success'); - expect(finalSnapshot.context.results.settleup).toBe('SettleUp Success'); + expect(finalSnapshot.matches("success")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); + expect(finalSnapshot.context.results.settleup).toBe("SettleUp Success"); expect(finalSnapshot.context.errors.ynab).toBe(null); expect(finalSnapshot.context.errors.settleup).toBe(null); actor.stop(); }); - it('should reach success state when only YNAB is targeted and succeeds', async () => { + it("should reach success state when only YNAB is targeted and succeeds", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -219,28 +241,39 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('success') || snapshot.matches('error') || snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("success") || + snapshot.matches("error") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('success')).toBe(true); - expect(finalSnapshot.context.results.ynab).toBe('YNAB Success'); + expect(finalSnapshot.matches("success")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); expect(finalSnapshot.context.results.settleup).toBe(null); actor.stop(); }); }); - describe('Error Scenarios', () => { - it('should reach error state when single targeted API fails', async () => { + describe("Error Scenarios", () => { + it("should reach error state when single targeted API fails", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -248,26 +281,35 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('error') || snapshot.matches('success') || snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("error") || + snapshot.matches("success") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('error')).toBe(true); + expect(finalSnapshot.matches("error")).toBe(true); expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); - expect(finalSnapshot.context.errors.ynab.message).toBe('YNAB Failed'); + expect(finalSnapshot.context.errors.ynab.message).toBe("YNAB Failed"); actor.stop(); }); - it('should reach partialSuccess state when one of multiple APIs fails', async () => { + it("should reach partialSuccess state when one of multiple APIs fails", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -275,26 +317,37 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess') || snapshot.matches('error') || snapshot.matches('success'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('partialSuccess')).toBe(true); - expect(finalSnapshot.context.results.ynab).toBe('YNAB Success'); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); actor.stop(); }); - it('should reach partialSuccess when YNAB fails but SettleUp succeeds', async () => { + it("should reach partialSuccess when YNAB fails but SettleUp succeeds", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -302,29 +355,38 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess') || snapshot.matches('error') || snapshot.matches('success'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('partialSuccess')).toBe(true); - expect(finalSnapshot.context.results.settleup).toBe('SettleUp Success'); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.settleup).toBe("SettleUp Success"); expect(finalSnapshot.context.results.ynab).toBe(null); expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); - expect(finalSnapshot.context.errors.ynab.message).toBe('YNAB Failed'); + expect(finalSnapshot.context.errors.ynab.message).toBe("YNAB Failed"); expect(finalSnapshot.context.errors.settleup).toBe(null); actor.stop(); }); - it('should reach partialSuccess when SettleUp fails but YNAB succeeds', async () => { + it("should reach partialSuccess when SettleUp fails but YNAB succeeds", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -332,29 +394,42 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess') || snapshot.matches('error') || snapshot.matches('success'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('partialSuccess')).toBe(true); - expect(finalSnapshot.context.results.ynab).toBe('YNAB Success'); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); expect(finalSnapshot.context.results.settleup).toBe(null); expect(finalSnapshot.context.errors.ynab).toBe(null); expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); - expect(finalSnapshot.context.errors.settleup.message).toBe('SettleUp Failed'); + expect(finalSnapshot.context.errors.settleup.message).toBe( + "SettleUp Failed", + ); actor.stop(); }); - it('should reach error state when all targeted APIs fail', async () => { + it("should reach error state when all targeted APIs fail", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -362,15 +437,22 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); - - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('error') || snapshot.matches('success') || snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("error") || + snapshot.matches("success") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); const finalSnapshot = actor.getSnapshot(); - expect(finalSnapshot.matches('error')).toBe(true); + expect(finalSnapshot.matches("error")).toBe(true); expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); @@ -378,12 +460,16 @@ describe('apiSyncStateMachine', () => { }); }); - describe('Retry Logic', () => { - it('should retry all APIs when in error state', async () => { + describe("Retry Logic", () => { + it("should retry all APIs when in error state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -391,29 +477,40 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for error state - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('error'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("error"); + }, + { timeout: 1000 }, + ); // Test RETRY from error state - actor.send({ type: 'RETRY' }); + actor.send({ type: "RETRY" }); const retrySnapshot = actor.getSnapshot(); - expect(retrySnapshot.matches('syncing')).toBe(true); - expect(retrySnapshot.context.results).toEqual({ ynab: null, settleup: null }); - expect(retrySnapshot.context.errors).toEqual({ ynab: null, settleup: null }); + expect(retrySnapshot.matches("syncing")).toBe(true); + expect(retrySnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(retrySnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); actor.stop(); }); - it('should retry only failed APIs when in partialSuccess state', async () => { + it("should retry only failed APIs when in partialSuccess state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -421,19 +518,22 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for partialSuccess - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); // Test RETRY_FAILED - actor.send({ type: 'RETRY_FAILED' }); + actor.send({ type: "RETRY_FAILED" }); const retrySnapshot = actor.getSnapshot(); - - expect(retrySnapshot.matches('syncing')).toBe(true); + + expect(retrySnapshot.matches("syncing")).toBe(true); // Should only target the failed API (settleup) - ynab was successful so errors.ynab should be null expect(retrySnapshot.context.formState.target.ynab).toBe(false); // Was successful, so not retried expect(retrySnapshot.context.formState.target.settleup).toBe(true); // Failed, so retried @@ -441,11 +541,15 @@ describe('apiSyncStateMachine', () => { actor.stop(); }); - it('should retry only YNAB when it failed in partialSuccess state', async () => { + it("should retry only YNAB when it failed in partialSuccess state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -453,19 +557,22 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for partialSuccess - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); // Test RETRY_FAILED - actor.send({ type: 'RETRY_FAILED' }); + actor.send({ type: "RETRY_FAILED" }); const retrySnapshot = actor.getSnapshot(); - - expect(retrySnapshot.matches('syncing')).toBe(true); + + expect(retrySnapshot.matches("syncing")).toBe(true); // Should only target the failed API (ynab) - settleup was successful expect(retrySnapshot.context.formState.target.ynab).toBe(true); // Failed, so retried expect(retrySnapshot.context.formState.target.settleup).toBe(false); // Was successful, so not retried @@ -474,12 +581,14 @@ describe('apiSyncStateMachine', () => { }); }); - describe('Reset Functionality', () => { - it('should reset to idle from success state', async () => { + describe("Reset Functionality", () => { + it("should reset to idle from success state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -487,29 +596,42 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('success'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("success"); + }, + { timeout: 1000 }, + ); - actor.send({ type: 'RESET' }); + actor.send({ type: "RESET" }); const resetSnapshot = actor.getSnapshot(); - - expect(resetSnapshot.matches('idle')).toBe(true); + + expect(resetSnapshot.matches("idle")).toBe(true); expect(resetSnapshot.context.formState).toBe(null); - expect(resetSnapshot.context.results).toEqual({ ynab: null, settleup: null }); - expect(resetSnapshot.context.errors).toEqual({ ynab: null, settleup: null }); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); actor.stop(); }); - it('should reset to idle from error state', async () => { + it("should reset to idle from error state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.reject(new Error('YNAB Failed'))), - submitSettleup: fromPromise(() => Promise.resolve('SettleUp Success')), + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), }, }); @@ -517,30 +639,41 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for error state - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('error'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("error"); + }, + { timeout: 1000 }, + ); - actor.send({ type: 'RESET' }); + actor.send({ type: "RESET" }); const resetSnapshot = actor.getSnapshot(); - - expect(resetSnapshot.matches('idle')).toBe(true); + + expect(resetSnapshot.matches("idle")).toBe(true); expect(resetSnapshot.context.formState).toBe(null); - expect(resetSnapshot.context.results).toEqual({ ynab: null, settleup: null }); - expect(resetSnapshot.context.errors).toEqual({ ynab: null, settleup: null }); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); actor.stop(); }); - it('should reset to idle from partialSuccess state', async () => { + it("should reset to idle from partialSuccess state", async () => { const machineWithMockServices = apiSyncStateMachine.provide({ actors: { - submitYnab: fromPromise(() => Promise.resolve('YNAB Success')), - submitSettleup: fromPromise(() => Promise.reject(new Error('SettleUp Failed'))), + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), }, }); @@ -548,38 +681,47 @@ describe('apiSyncStateMachine', () => { actor.start(); const formState = createMockFormState({ ynab: true, settleup: true }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); // Wait for partialSuccess - await vi.waitFor(() => { - const snapshot = actor.getSnapshot(); - return snapshot.matches('partialSuccess'); - }, { timeout: 1000 }); + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); - actor.send({ type: 'RESET' }); + actor.send({ type: "RESET" }); const resetSnapshot = actor.getSnapshot(); - - expect(resetSnapshot.matches('idle')).toBe(true); + + expect(resetSnapshot.matches("idle")).toBe(true); expect(resetSnapshot.context.formState).toBe(null); - expect(resetSnapshot.context.results).toEqual({ ynab: null, settleup: null }); - expect(resetSnapshot.context.errors).toEqual({ ynab: null, settleup: null }); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); actor.stop(); }); }); - describe('Context Management', () => { - it('should store formState correctly when START_SYNC is sent', () => { + describe("Context Management", () => { + it("should store formState correctly when START_SYNC is sent", () => { const actor = createActor(apiSyncStateMachine); actor.start(); const formState = createMockFormState({ ynab: true, settleup: false }); - actor.send({ type: 'START_SYNC', formState }); + actor.send({ type: "START_SYNC", formState }); const snapshot = actor.getSnapshot(); expect(snapshot.context.formState.target.ynab).toBe(true); expect(snapshot.context.formState.target.settleup).toBe(false); - expect(snapshot.context.formState.description).toBe('Test Transaction'); + expect(snapshot.context.formState.description).toBe("Test Transaction"); expect(snapshot.context.formState.amountMilliunits).toBe(25000); expect(snapshot.context.results).toEqual({ ynab: null, settleup: null }); expect(snapshot.context.errors).toEqual({ ynab: null, settleup: null }); @@ -587,4 +729,4 @@ describe('apiSyncStateMachine', () => { actor.stop(); }); }); -}); \ No newline at end of file +}); From 9f38fb4d8f41d3df249d6684799b9060ab792de2 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sun, 25 Jan 2026 23:37:03 +0100 Subject: [PATCH 7/9] Refactor ApiSyncStepper to simplify state logic and improve color handling for API steps --- src/StateMachineTest.jsx | 48 +-- src/components/ApiSyncStepper.jsx | 505 ++++++++++++++---------------- 2 files changed, 246 insertions(+), 307 deletions(-) diff --git a/src/StateMachineTest.jsx b/src/StateMachineTest.jsx index 7b22a97..1cc2ea5 100644 --- a/src/StateMachineTest.jsx +++ b/src/StateMachineTest.jsx @@ -127,29 +127,6 @@ function StateMachineTest() { {/* API Sync Stepper */} - {/* Context Display */} -
-

Context:

-
{JSON.stringify(state.context, null, 2)}
-
- - {/* Debug Info */} -
-

Debug Info:

-

YNAB Resolver: {ynabResolver ? "SET" : "NULL"}

-

SettleUp Resolver: {settleupResolver ? "SET" : "NULL"}

-

Check browser console for detailed logs

-
- {/* Start Controls */}

Start Sync:

@@ -404,29 +381,12 @@ function StateMachineTest() {
- {/* Instructions */} + {/* Context Display */}
-

Instructions:

-
    -
  1. - Click one of the "Start Sync" buttons to select which APIs - to call -
  2. -
  3. - When APIs start (⏳ Waiting...), use - "Success"/"Fail" buttons to control outcomes -
  4. -
  5. Watch how the state machine transitions based on your choices
  6. -
  7. - Test different combinations: both succeed, both fail, one of each -
  8. -
+

Context:

+
{JSON.stringify(state.context, null, 2)}
); diff --git a/src/components/ApiSyncStepper.jsx b/src/components/ApiSyncStepper.jsx index 95795ec..c13c796 100644 --- a/src/components/ApiSyncStepper.jsx +++ b/src/components/ApiSyncStepper.jsx @@ -2,12 +2,47 @@ import PropTypes from "prop-types"; import React from "react"; import { MdCheck, MdClose, MdCircle, MdMoreHoriz } from "react-icons/md"; +// Color constants +const COLORS = { + current: "#3b82f6", // blue + success: "#22c55e", // green + error: "#ef4444", // red + disabled: "#9ca3af", // gray + inactive: "#9ca3af", // same gray (but semantically different) +}; + const ApiSyncStepper = ({ state, context }) => { const currentState = state?.value || "idle"; const formState = context?.formState; const results = context?.results || {}; const errors = context?.errors || {}; + // Helper function for API step state logic (ynab/settleup) + const getApiStepState = (apiName) => { + if (!formState?.target?.[apiName]) return "disabled"; + + // Check if we're in any syncing state + if ( + currentState === "syncing" || + (typeof currentState === "object" && currentState.syncing) + ) { + // Try to get nested state from different possible structures + const apiState = + state?.value?.syncing?.[apiName] || + (state?.value && typeof state.value === "object" + ? state.value.syncing?.[apiName] + : null); + if (apiState === "submitting") return "current"; + if (apiState === "success") return "success"; + if (apiState === "error") return "error"; + // If API is targeted and we're syncing, show as current + return "current"; + } + if (results[apiName] && !errors[apiName]) return "success"; + if (errors[apiName]) return "error"; + return "inactive"; + }; + // Simplified step state determination const getStepState = (stepName) => { switch (stepName) { @@ -15,50 +50,8 @@ const ApiSyncStepper = ({ state, context }) => { return currentState === "idle" ? "current" : "success"; case "ynab": - if (!formState?.target?.ynab) return "disabled"; - // Check if we're in any syncing state - if ( - currentState === "syncing" || - (typeof currentState === "object" && currentState.syncing) - ) { - // Try to get nested state from different possible structures - const ynabState = - state?.value?.syncing?.ynab || - (state?.value && typeof state.value === "object" - ? state.value.syncing?.ynab - : null); - if (ynabState === "submitting") return "current"; - if (ynabState === "success") return "success"; - if (ynabState === "error") return "error"; - // If YNAB is targeted and we're syncing, show as current - return "current"; - } - if (results.ynab && !errors.ynab) return "success"; - if (errors.ynab) return "error"; - return "inactive"; - case "settleup": - if (!formState?.target?.settleup) return "disabled"; - // Check if we're in any syncing state - if ( - currentState === "syncing" || - (typeof currentState === "object" && currentState.syncing) - ) { - // Try to get nested state from different possible structures - const settleupState = - state?.value?.syncing?.settleup || - (state?.value && typeof state.value === "object" - ? state.value.syncing?.settleup - : null); - if (settleupState === "submitting") return "current"; - if (settleupState === "success") return "success"; - if (settleupState === "error") return "error"; - // If SettleUp is targeted and we're syncing, show as current - return "current"; - } - if (results.settleup && !errors.settleup) return "success"; - if (errors.settleup) return "error"; - return "inactive"; + return getApiStepState(stepName); case "complete": return ["success", "error", "partialSuccess"].includes(currentState) @@ -81,65 +74,21 @@ const ApiSyncStepper = ({ state, context }) => { complete: getStepState("complete"), }; - // Simplified color and line logic + // Simplified color logic const getColor = (stepState) => { - const colors = { - current: "#3b82f6", // blue - success: "#22c55e", // green - error: "#ef4444", // red - disabled: "#9ca3af", // gray - inactive: "#9ca3af", // same gray as disabled - }; - return colors[stepState] || colors.inactive; + return COLORS[stepState] || COLORS.inactive; }; const getOpacity = (stepState) => { - return stepState === "inactive" ? 0.6 : 1; - }; - - // New line color logic - const getLineColor = (startStep, endStep) => { - const startColor = getColor(startStep); - const endColor = getColor(endStep); - - // If both have same color, use that color - if (startColor === endColor) { - return startColor; - } - - // Otherwise use end step color - return endColor; + return stepState === "disabled" ? 0.5 : 1; }; - const getEntryToSplitColor = () => { - // Priority to active branch if only one is active - const ynabActive = steps.ynab !== "disabled"; - const settleupActive = steps.settleup !== "disabled"; - - if (ynabActive && !settleupActive) { - return getColor(steps.ynab); - } - if (settleupActive && !ynabActive) { - return getColor(steps.settleup); - } - - // If both active or both inactive, use entry color - return getColor(steps.entry); - }; - - const getBranchToJoinColor = (branchStep) => { - // If branch is disabled/inactive, keep branch color - if (branchStep === "disabled" || branchStep === "inactive") { - return getColor(branchStep); - } - - // Otherwise use complete color - return getColor(steps.complete); - }; - - const getJoinToCompleteColor = () => { - // Use complete color - return getColor(steps.complete); + // Line color based on destination step + const getLineColor = (endStepState, branchIsDisabled = false) => { + // If on a disabled branch, use disabled color + if (branchIsDisabled) return getColor("disabled"); + // Otherwise use the destination step's color + return getColor(endStepState); }; const StepIcon = ({ stepState, size = 24 }) => { @@ -164,7 +113,10 @@ const ApiSyncStepper = ({ state, context }) => { const StepCircle = ({ stepState, label, x, y }) => { const color = getColor(stepState); - const opacity = stepState === "disabled" ? 0.5 : getOpacity(stepState); + // Only apply disabled opacity to API steps after entry completes + const isApiStep = label === "YNAB" || label === "SettleUp"; + const opacity = + isApiStep && steps.entry !== "success" ? 1 : getOpacity(stepState); return ( @@ -229,7 +181,13 @@ const ApiSyncStepper = ({ state, context }) => { // Helper function to create path elements const createPath = (d, stroke, strokeWidth = 3) => ( - + ); const createLine = (x1, y1, x2, y2, stroke, strokeWidth = 3) => ( @@ -240,6 +198,7 @@ const ApiSyncStepper = ({ state, context }) => { y2={y2} stroke={stroke} strokeWidth={strokeWidth} + strokeLinecap="round" /> ); @@ -258,13 +217,43 @@ const ApiSyncStepper = ({ state, context }) => { cornerRadius: 15, }; - // Calculate all line colors using new logic + // Calculate line colors based on destination steps + const getEntryToSplitColor = () => { + const activeBranches = [steps.ynab, steps.settleup].filter( + (step) => step !== "disabled", + ); + + // If no active branches, use disabled color + if (activeBranches.length === 0) return getColor("disabled"); + + // If only one active branch, use its color + if (activeBranches.length === 1) return getColor(activeBranches[0]); + + // Both active - return color by priority: error > current > success > inactive + const statePriority = ["error", "current", "success", "inactive"]; + + for (const state of statePriority) { + if (activeBranches.includes(state)) return getColor(state); + } + + return getColor(steps.entry); // fallback + }; + const entryToSplitColor = getEntryToSplitColor(); - const ynabBranchColor = getColor(steps.ynab); - const settleupBranchColor = getColor(steps.settleup); - const ynabToJoinColor = getBranchToJoinColor(steps.ynab); - const settleupToJoinColor = getBranchToJoinColor(steps.settleup); - const joinToCompleteColor = getJoinToCompleteColor(); + const ynabBranchColor = getLineColor(steps.ynab, steps.ynab === "disabled"); + const settleupBranchColor = getLineColor( + steps.settleup, + steps.settleup === "disabled", + ); + const ynabToJoinColor = getLineColor( + steps.complete, + steps.ynab === "disabled", + ); + const settleupToJoinColor = getLineColor( + steps.complete, + steps.settleup === "disabled", + ); + const joinToCompleteColor = getLineColor(steps.complete); // Extract error information const errorList = []; @@ -281,176 +270,166 @@ const ApiSyncStepper = ({ state, context }) => { return (
-
-

- API Sync Progress -

- - - {/* Main horizontal line from Entry to split */} - {createLine( - layout.entryX + 20, - layout.centerY, - layout.splitX - layout.cornerRadius, - layout.centerY, - entryToSplitColor, - )} + + {/* Main horizontal line from Entry to split */} + {createLine( + layout.entryX + 20, + layout.centerY, + layout.splitX - layout.cornerRadius, + layout.centerY, + entryToSplitColor, + )} - {/* YNAB branch */} - - {/* Split to YNAB - use YNAB color for the corner */} - {createPath( - `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + {/* YNAB branch */} + + {/* Split to YNAB - use YNAB color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY - layout.cornerRadius}`, - ynabBranchColor, - )} - {createLine( - layout.splitX, - layout.centerY - layout.cornerRadius, - layout.splitX, - layout.ynabY + layout.cornerRadius, - ynabBranchColor, - )} - {createPath( - `M ${layout.splitX} ${layout.ynabY + layout.cornerRadius} + ynabBranchColor, + )} + {createLine( + layout.splitX, + layout.centerY - layout.cornerRadius, + layout.splitX, + layout.ynabY + layout.cornerRadius, + ynabBranchColor, + )} + {createPath( + `M ${layout.splitX} ${layout.ynabY + layout.cornerRadius} Q ${layout.splitX} ${layout.ynabY}, ${layout.splitX + layout.cornerRadius} ${layout.ynabY}`, - ynabBranchColor, - )} - {createLine( - layout.splitX + layout.cornerRadius, - layout.ynabY, - layout.branchX - 20, - layout.ynabY, - ynabBranchColor, - )} - - {/* YNAB to join - use branch-to-join color */} - {createLine( - layout.branchX + 20, - layout.ynabY, - layout.joinX - layout.cornerRadius, - layout.ynabY, - ynabToJoinColor, - )} - {createPath( - `M ${layout.joinX - layout.cornerRadius} ${layout.ynabY} + ynabBranchColor, + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.ynabY, + layout.branchX - 20, + layout.ynabY, + ynabBranchColor, + )} + + {/* YNAB to join - use branch-to-join color */} + {createLine( + layout.branchX + 20, + layout.ynabY, + layout.joinX - layout.cornerRadius, + layout.ynabY, + ynabToJoinColor, + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.ynabY} Q ${layout.joinX} ${layout.ynabY}, ${layout.joinX} ${layout.ynabY + layout.cornerRadius}`, - ynabToJoinColor, - )} - {createLine( - layout.joinX, - layout.ynabY + layout.cornerRadius, - layout.joinX, - layout.centerY - layout.cornerRadius, - ynabToJoinColor, - )} - {/* Corner from YNAB vertical to center horizontal */} - {createPath( - `M ${layout.joinX} ${layout.centerY - layout.cornerRadius} + ynabToJoinColor, + )} + {createLine( + layout.joinX, + layout.ynabY + layout.cornerRadius, + layout.joinX, + layout.centerY - layout.cornerRadius, + ynabToJoinColor, + )} + {/* Corner from YNAB vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY - layout.cornerRadius} Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, - ynabToJoinColor, - )} - - - {/* SettleUp branch */} - - {/* Split to SettleUp - use SettleUp color for the corner */} - {createPath( - `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + ynabToJoinColor, + )} + + + {/* SettleUp branch */} + + {/* Split to SettleUp - use SettleUp color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY + layout.cornerRadius}`, - settleupBranchColor, - )} - {createLine( - layout.splitX, - layout.centerY + layout.cornerRadius, - layout.splitX, - layout.settleupY - layout.cornerRadius, - settleupBranchColor, - )} - {createPath( - `M ${layout.splitX} ${layout.settleupY - layout.cornerRadius} + settleupBranchColor, + )} + {createLine( + layout.splitX, + layout.centerY + layout.cornerRadius, + layout.splitX, + layout.settleupY - layout.cornerRadius, + settleupBranchColor, + )} + {createPath( + `M ${layout.splitX} ${layout.settleupY - layout.cornerRadius} Q ${layout.splitX} ${layout.settleupY}, ${layout.splitX + layout.cornerRadius} ${layout.settleupY}`, - settleupBranchColor, - )} - {createLine( - layout.splitX + layout.cornerRadius, - layout.settleupY, - layout.branchX - 20, - layout.settleupY, - settleupBranchColor, - )} - - {/* SettleUp to join - use branch-to-join color */} - {createLine( - layout.branchX + 20, - layout.settleupY, - layout.joinX - layout.cornerRadius, - layout.settleupY, - settleupToJoinColor, - )} - {createPath( - `M ${layout.joinX - layout.cornerRadius} ${layout.settleupY} - Q ${layout.joinX} ${layout.settleupY}, ${layout.joinX} ${layout.settleupY - layout.cornerRadius}`, - settleupToJoinColor, - )} - {createLine( - layout.joinX, - layout.settleupY - layout.cornerRadius, - layout.joinX, - layout.centerY + layout.cornerRadius, - settleupToJoinColor, - )} - {/* Corner from SettleUp vertical to center horizontal */} - {createPath( - `M ${layout.joinX} ${layout.centerY + layout.cornerRadius} - Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, - settleupToJoinColor, - )} - + settleupBranchColor, + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.settleupY, + layout.branchX - 20, + layout.settleupY, + settleupBranchColor, + )} - {/* Final line to complete */} + {/* SettleUp to join - use branch-to-join color */} {createLine( - layout.joinX + layout.cornerRadius, - layout.centerY, - layout.completeX - 20, - layout.centerY, - joinToCompleteColor, + layout.branchX + 20, + layout.settleupY, + layout.joinX - layout.cornerRadius, + layout.settleupY, + settleupToJoinColor, + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.settleupY} + Q ${layout.joinX} ${layout.settleupY}, ${layout.joinX} ${layout.settleupY - layout.cornerRadius}`, + settleupToJoinColor, )} + {createLine( + layout.joinX, + layout.settleupY - layout.cornerRadius, + layout.joinX, + layout.centerY + layout.cornerRadius, + settleupToJoinColor, + )} + {/* Corner from SettleUp vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY + layout.cornerRadius} + Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, + settleupToJoinColor, + )} + + + {/* Final line to complete */} + {createLine( + layout.joinX + layout.cornerRadius, + layout.centerY, + layout.completeX - 20, + layout.centerY, + joinToCompleteColor, + )} - {/* Step circles */} - - - - - -
+ {/* Step circles */} + + + + + {/* Error Display */} {errorList.length > 0 && ( From 7c21865ada834e39adc11e89ba6714515043e955 Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Mon, 26 Jan 2026 00:00:24 +0100 Subject: [PATCH 8/9] Add unit test for ApiSyncStepper --- src/components/ApiSyncStepper.jsx | 16 +- src/components/ApiSyncStepper.test.jsx | 379 +++++++++++++++++++++++++ src/version.js | 4 +- 3 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 src/components/ApiSyncStepper.test.jsx diff --git a/src/components/ApiSyncStepper.jsx b/src/components/ApiSyncStepper.jsx index c13c796..e2e1b14 100644 --- a/src/components/ApiSyncStepper.jsx +++ b/src/components/ApiSyncStepper.jsx @@ -26,12 +26,8 @@ const ApiSyncStepper = ({ state, context }) => { currentState === "syncing" || (typeof currentState === "object" && currentState.syncing) ) { - // Try to get nested state from different possible structures - const apiState = - state?.value?.syncing?.[apiName] || - (state?.value && typeof state.value === "object" - ? state.value.syncing?.[apiName] - : null); + // Get nested state from syncing structure + const apiState = state?.value?.syncing?.[apiName]; if (apiState === "submitting") return "current"; if (apiState === "success") return "success"; if (apiState === "error") return "error"; @@ -49,10 +45,6 @@ const ApiSyncStepper = ({ state, context }) => { case "entry": return currentState === "idle" ? "current" : "success"; - case "ynab": - case "settleup": - return getApiStepState(stepName); - case "complete": return ["success", "error", "partialSuccess"].includes(currentState) ? currentState === "success" @@ -62,8 +54,8 @@ const ApiSyncStepper = ({ state, context }) => { : "inactive" : "inactive"; - default: - return "inactive"; + default: // ynab or settleup + return getApiStepState(stepName); } }; diff --git a/src/components/ApiSyncStepper.test.jsx b/src/components/ApiSyncStepper.test.jsx new file mode 100644 index 0000000..6ff33bd --- /dev/null +++ b/src/components/ApiSyncStepper.test.jsx @@ -0,0 +1,379 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import ApiSyncStepper from "./ApiSyncStepper"; + +describe("ApiSyncStepper", () => { + const mockFormState = { + target: { + ynab: true, + settleup: true, + }, + }; + + const createMockState = (stateValue) => ({ value: stateValue }); + const createMockContext = ( + formState = mockFormState, + results = {}, + errors = {}, + ) => ({ + formState, + results, + errors, + }); + + describe("Step State Logic", () => { + test("should show entry as current when in idle state", () => { + const state = createMockState("idle"); + const context = createMockContext(); + + render(); + + // Entry should be visible + expect(screen.getByText("Entry")).toBeInTheDocument(); + }); + + test("should show entry as success when not in idle state", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + // Should render without crashing and show all labels + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle disabled API steps when not targeted", () => { + const state = createMockState("idle"); + const context = createMockContext({ + target: { ynab: false, settleup: false }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as current when syncing", () => { + const state = createMockState({ + syncing: { ynab: "submitting", settleup: "submitting" }, + }); + const context = createMockContext(); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as success when completed successfully", () => { + const state = createMockState("syncing"); + const context = createMockContext(mockFormState, { + ynab: { success: true }, + settleup: { success: true }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as error when failed", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { ynab: "API Error", settleup: "Network Error" }, + ); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Nested State Handling", () => { + test("should handle complex nested state structures", () => { + const state = { + value: { + syncing: { + ynab: "success", + settleup: "error", + }, + }, + }; + const context = createMockContext(); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Complete Step Logic", () => { + test("should show complete as success when in success state", () => { + const state = createMockState("success"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as error when in error state", () => { + const state = createMockState("error"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as inactive when in partial success", () => { + const state = createMockState("partialSuccess"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as inactive when not in final state", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Error Display", () => { + test("should display errors when present", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: "YNAB API connection failed", + settleup: "Invalid SettleUp credentials", + }, + ); + + render(); + + expect(screen.getByText("Errors")).toBeInTheDocument(); + expect(screen.getByText("YNAB:")).toBeInTheDocument(); + expect(screen.getByText("YNAB API connection failed")).toBeInTheDocument(); + expect(screen.getByText("SettleUp:")).toBeInTheDocument(); + expect( + screen.getByText("Invalid SettleUp credentials"), + ).toBeInTheDocument(); + }); + + test("should handle error objects with message property", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: { message: "Connection timeout" }, + settleup: { message: "Rate limit exceeded" }, + }, + ); + + render(); + + expect(screen.getByText("Connection timeout")).toBeInTheDocument(); + expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument(); + }); + + test("should handle error objects without message property", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: { code: 500 }, + settleup: { status: "failed" }, + }, + ); + + render(); + + // Should show "Unknown error" for both APIs since they lack message property + const unknownErrors = screen.getAllByText("Unknown error"); + expect(unknownErrors).toHaveLength(2); + }); + + test("should not display error section when no errors", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + expect(screen.queryByText("Errors")).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases and Null Handling", () => { + test("should handle null state gracefully", () => { + const context = createMockContext(); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle undefined state gracefully", () => { + const context = createMockContext(); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle null context gracefully", () => { + const state = createMockState("idle"); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle missing formState gracefully", () => { + const state = createMockState("idle"); + const context = { results: {}, errors: {} }; + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle missing target configuration", () => { + const state = createMockState("syncing"); + const context = createMockContext({ target: null }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Mixed Scenarios", () => { + test("should handle only YNAB enabled", () => { + const state = createMockState("syncing"); + const context = createMockContext({ + target: { ynab: true, settleup: false }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should handle only SettleUp enabled", () => { + const state = createMockState("syncing"); + const context = createMockContext({ + target: { ynab: false, settleup: true }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should handle partial success with some errors", () => { + const state = createMockState("partialSuccess"); + const context = createMockContext( + mockFormState, + { ynab: { success: true } }, + { settleup: "Failed to submit" }, + ); + + render(); + + expect(screen.getByText("Errors")).toBeInTheDocument(); + expect(screen.getByText("SettleUp:")).toBeInTheDocument(); + expect(screen.getByText("Failed to submit")).toBeInTheDocument(); + }); + + test("should handle unknown step names gracefully", () => { + const state = createMockState("unknownState"); + const context = createMockContext(); + + render(); + + // Should still render all expected steps + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("SVG Rendering", () => { + test("should render SVG with correct structure and animations", () => { + const state = createMockState({ + syncing: { ynab: "submitting", settleup: "idle" }, + }); + const context = createMockContext(); + + render(); + + // Check SVG structure + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 800 280"); + expect(svg).toHaveClass("w-full", "h-auto"); + + // Check that SVG elements are present + const circles = document.querySelectorAll("circle"); + expect(circles.length).toBeGreaterThan(0); + + // Check for animations (pulse for current state) + const animations = document.querySelectorAll("animate"); + expect(animations.length).toBeGreaterThan(0); + + // Check for connecting elements + const lines = document.querySelectorAll("line"); + const paths = document.querySelectorAll("path"); + expect(lines.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + }); + }); + + describe("Component Structure", () => { + test("should render with correct container structure", () => { + const state = createMockState("idle"); + const context = createMockContext(); + + const { container } = render( + , + ); + + expect(container.firstChild).toHaveClass("w-full"); + }); + }); +}); \ No newline at end of file diff --git a/src/version.js b/src/version.js index dbb949e..f00b3fb 100644 --- a/src/version.js +++ b/src/version.js @@ -4,9 +4,9 @@ export const VERSION_INFO = { "tag": "1.2.0", "branch": "87-implement-state-machine-with-stepper-visualization-for-api-call-coordination-ynab-settleup", - "commit": "db9640f", + "commit": "9f38fb4", "isDirty": true, - "buildTime": "2026-01-25T19:41:23.714Z", + "buildTime": "2026-01-25T22:58:40.859Z", "version": "1.2.0", "shortVersion": "1.2.0" }; From 7bbead54add7960eee6e6316842894c006b609ea Mon Sep 17 00:00:00 2001 From: Silma Thoron Date: Sat, 6 Jun 2026 17:33:41 +0200 Subject: [PATCH 9/9] Rename App component to ExpenseForm --- README.md | 2 +- src/{App.jsx => ExpenseForm.jsx} | 6 +++--- src/MainFormPage.jsx | 4 ++-- src/version.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/{App.jsx => ExpenseForm.jsx} (99%) diff --git a/README.md b/README.md index 5692ff2..92125cd 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The app is optimized for static hosting and works great with services like Nginx ```text src/ -├── App.jsx # Main application component +├── ExpenseForm.jsx # Main expense form component ├── AppContext.jsx # Global application context ├── AuthProvider.jsx # SettleUp authentication ├── MainFormPage.jsx # Main form interface diff --git a/src/App.jsx b/src/ExpenseForm.jsx similarity index 99% rename from src/App.jsx rename to src/ExpenseForm.jsx index 3a490d4..8624424 100644 --- a/src/App.jsx +++ b/src/ExpenseForm.jsx @@ -20,7 +20,7 @@ import { formStatePropType } from "./propTypes.js"; import { getClosestLocation } from "./utils/ynabUtils.js"; import "./index.css"; -export default function App({ onSubmit, formState, setFormState }) { +export default function ExpenseForm({ onSubmit, formState, setFormState }) { const { ynabAPI, budgetId, setAccounts } = useAppContext(); const { token: settleUpToken, user: settleUpUser } = useAuth(); const [payees, setPayees] = useState([]); @@ -429,8 +429,8 @@ export default function App({ onSubmit, formState, setFormState }) { ); } -App.propTypes = { +ExpenseForm.propTypes = { onSubmit: PropTypes.func.isRequired, formState: formStatePropType.isRequired, setFormState: PropTypes.func.isRequired, -}; +}; \ No newline at end of file diff --git a/src/MainFormPage.jsx b/src/MainFormPage.jsx index cfe0179..0acb44b 100644 --- a/src/MainFormPage.jsx +++ b/src/MainFormPage.jsx @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import { useState } from "react"; -import App from "./App.jsx"; +import ExpenseForm from "./ExpenseForm.jsx"; import { useAuth } from "./AuthProvider.jsx"; import CenteredCardLayout from "./components/CenteredCardLayout.jsx"; import { formStatePropType } from "./propTypes"; @@ -35,7 +35,7 @@ export default function MainFormPage({ Logout
- setShowReview(true)} formState={formState} setFormState={setFormState} diff --git a/src/version.js b/src/version.js index f00b3fb..67ec15d 100644 --- a/src/version.js +++ b/src/version.js @@ -4,9 +4,9 @@ export const VERSION_INFO = { "tag": "1.2.0", "branch": "87-implement-state-machine-with-stepper-visualization-for-api-call-coordination-ynab-settleup", - "commit": "9f38fb4", + "commit": "7c21865", "isDirty": true, - "buildTime": "2026-01-25T22:58:40.859Z", + "buildTime": "2026-01-26T22:02:13.869Z", "version": "1.2.0", "shortVersion": "1.2.0" };