From 42ab2d17c09e8e4b902d5b69fd2d46cf598e89cf Mon Sep 17 00:00:00 2001 From: 0xsemaj Date: Thu, 28 May 2026 21:23:48 -0700 Subject: [PATCH 01/35] Feat/frontend redesign (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update webhook service taxonomy and guard against undefined service - Rename service values from signals.vss/events.behavior/events.safety to signals/events - Prefix condition field names with vss. namespace - Merge event name options into single events key - Change isEventService to use strict equality instead of startsWith (fixes crash when service is undefined) - Add service default value to NewWebhookForm to prevent undefined on initial render Co-Authored-By: Claude Sonnet 4.6 * Bump yaml from 2.8.2 to 2.8.3 Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3) --- updated-dependencies: - dependency-name: yaml dependency-version: 2.8.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] * Update constants and vehicle details * Update burn * Enable Stale vehicle removal * Add debug * Add delete check * Fix db anomaly * Add SACD * setPermissions fix * powertraintype fix * Remove powertrainType that's throwing off the mint * fix SACD and add animation * Show button to simulator * Update to paymaster * Revert "Update to paymaster" This reverts commit 37f58c144438689bbb0d258861328d31ff19ee90. * Update app creation modal * Badge for simulated vehicles * Remove Tesla replace with Chevy * Configurator mis-disabling * Avoid 0x mismatch * Explorer init * fix 404 * Explorer work * compile issue fix * Type fix * init query * token xchange * query fix * vehicle pages * Remove double wrapping * debug * Avoid circular dep issue * path fix * move sdk to serverside * Add query * JSON update * error handling + navbar * new license card * fix * Sharing card * hide right column * fix transparent icon * SDK update * Bump lodash, @graphql-codegen/cli and @graphql-codegen/client-preset Bumps [lodash](https://github.com/lodash/lodash), [@graphql-codegen/cli](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/graphql-codegen-cli) and [@graphql-codegen/client-preset](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/presets/client). These dependencies needed to be updated together. Updates `lodash` from 4.17.21 to 4.18.1 - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1) Updates `@graphql-codegen/cli` from 5.0.7 to 6.2.1 - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/graphql-codegen-cli/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/@graphql-codegen/cli@6.2.1/packages/graphql-codegen-cli) Updates `@graphql-codegen/client-preset` from 4.8.3 to 5.2.4 - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/presets/client/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/HEAD/packages/presets/client) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: direct:production - dependency-name: "@graphql-codegen/cli" dependency-version: 6.2.1 dependency-type: direct:development - dependency-name: "@graphql-codegen/client-preset" dependency-version: 5.2.4 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * Improve API key gen exp * modal fix * Perf improvements on web3 transactions * More exp improvements * Add FleetOS tenant registration flow from API Keys section - New "Generate FleetOS Tenant" button: ensures fleets.dimo.co is an authorized redirect URI, generates an API key, obtains a DevJWT, and registers the tenant via POST to the FleetOS API in one guided flow - FleetOS signer address persisted to localStorage so the matching row is tagged with a "FleetOS" badge across page refreshes - Signer is rolled back on-chain if anything after enablement fails, since the private key is unknown to FleetOS at that point - Loading modal is now non-dismissable (backdrop + Escape blocked) while status is 'loading', preventing mid-flow exits across all operations Co-Authored-By: Claude Sonnet 4.6 * Regenerate GraphQL types for updated SignerFragment Added clientId and redirectURIs fields to SignerFragment require codegen output to be updated so the typed gql document resolves correctly at compile time. Co-Authored-By: Claude Sonnet 4.6 * Fix TDZ crash: move useFragment before useState that depends on it In production builds, minification causes a ReferenceError when fragment.clientId is accessed inside a useState initializer that appears before the fragment const declaration. Moving useFragment to the top of the component resolves the temporal dead zone. Co-Authored-By: Claude Sonnet 4.6 * Register FleetOS: rename, new icon, confirmation modal - Rename button to "Register FleetOS" with TruckIcon - Button now opens a confirmation modal explaining the multi-step flow before any transactions are initiated - Confirmation lists what will happen (redirect URI, API key, tenant registration) and warns not to close the window once started Co-Authored-By: Claude Sonnet 4.6 * Fix FleetOS button layout, modal centering, and copy - Wrap buttons in flex div so they render side by side - Remove className from Modal to preserve fixed overlay centering - Shorten confirmation modal copy Co-Authored-By: Claude Sonnet 4.6 * FleetOS: all-or-none rollback on failure - Track addedRedirectUri to distinguish newly-added from pre-existing - On failure: roll back signer and redirect URI only if created in this flow, leaving pre-existing redirect URIs untouched - Show "Rolling back changes..." status so the user knows cleanup is happening before the error is surfaced Co-Authored-By: Claude Sonnet 4.6 * RentalOS: update /tenant/register request shape with user profile data Rename FLEETS_REGISTER_ENDPOINT → RENTAL_OS_REGISTER_ENDPOINT and update the POST body to match the new API contract: adds wallet (smartContractAddress), email, first_name, last_name, and business_name fetched from the DB via getUserByToken. Co-Authored-By: Claude Sonnet 4.6 * Fix: use server action getUser instead of server-only service getUserByToken imports next/headers which can't run in a client component. Switch to the existing getUser server action wrapper. Co-Authored-By: Claude Sonnet 4.6 * Allow fleets.dimo.co in CSP connect-src Required for the RentalOS /tenant/register fetch from the browser. Co-Authored-By: Claude Sonnet 4.6 * RentalOS: wait for on-chain propagation, retry JWT + registration After enabling the signer and redirect URI, wait 8 s for auth servers to index the on-chain state before requesting a JWT. Then retry JWT generation up to 3x (5 s apart) and the registration call up to 3x (5 s apart) before giving up. Rollback only happens after all retries are exhausted. Co-Authored-By: Claude Sonnet 4.6 * Fix RentalOS tag not showing after wizard completes GraphQL returns addresses in lowercase; account.address is checksummed. Normalize to lowercase on save and comparison so the tag matches regardless of which source the signer row comes from. Co-Authored-By: Claude Sonnet 4.6 * RentalOS: use Developer JWT signed by API key, not Global Account key getGlobalAccountDeveloperJwt signs with the Turnkey embedded key (User JWT). Instead, call getDeveloperJwt directly with the newly generated signer private key so the Authorization header carries a proper Developer JWT tied to the developer license. Co-Authored-By: Claude Sonnet 4.6 * RentalOS: add console logging to diagnose 403 failures Logs getDeveloperJwt result/errors per attempt, the registration request body (API key redacted), and the full status + response body from each /tenant/register attempt. Co-Authored-By: Claude Sonnet 4.6 * Fix: strip 0x prefix from private key before getDeveloperJwt web3 returns privateKey with 0x prefix; the DIMO SDK expects raw hex. Matches how APIKeyModal displays the key and how Turnkey exports it. Co-Authored-By: Claude Sonnet 4.6 * Fix: strip 0x prefix from apiKey in /tenant/register body Co-Authored-By: Claude Sonnet 4.6 * Trim whitespace from business_name before /tenant/register Co-Authored-By: Claude Sonnet 4.6 * docs: add multi-configuration design spec One-to-many configurations per license — backend API changes, new list page at /configurator, create moves to /configurator/new. Co-Authored-By: Claude Sonnet 4.6 * docs: add multi-configuration implementation plan Co-Authored-By: Claude Sonnet 4.6 * feat: rename getConfigurationsByClientId to return array, add deleteConfiguration Co-Authored-By: Claude Sonnet 4.6 * refactor: consolidate [id] configurator form components to shared location Add configuration_id to shared SharedProps type, update [id] ConfigurationForm imports to reference shared components path, and delete the now-redundant duplicate files from the [id]-specific directory. Co-Authored-By: Claude Sonnet 4.6 * feat: add ConfigurationList component with edit/delete per row Co-Authored-By: Claude Sonnet 4.6 * feat: add /configurator list page and /configurator/new create route Moves DEVELOPER_LICENSE_INFO to ListView, replaces the root configurator page with a list view, and adds a /new route for creating configurations (redirects to list on save instead of the edit page). Co-Authored-By: Claude Sonnet 4.6 * feat: add /configurator list page and /configurator/new create route Co-Authored-By: Claude Sonnet 4.6 * feat: simplify Vehicles entry point — always route to configurator list * fix: update stale import path after [id] types consolidation Co-Authored-By: Claude Sonnet 4.6 * fix: update LicenseCard to use renamed getConfigurationsByClientId Co-Authored-By: Claude Sonnet 4.6 * fix: normalize getConfigurationsByClientId response to always return array Backend currently returns a single object; wrap it until API is updated. Co-Authored-By: Claude Sonnet 4.6 * feat: show configurator link with count when multiple configurations exist Single config still shows copy button; 2+ configs links to configurator list. Co-Authored-By: Claude Sonnet 4.6 * feat: add Copy Link action to configurator list Copies the login.dimo.org sharing URL directly from the list without entering each configuration. Co-Authored-By: Claude Sonnet 4.6 * feat: auto-open Register RentalOS modal via ?focus=rentals_os_signup Visitors arriving at console.dimo.org?focus=rentals_os_signup are now routed (post-login) to their first developer license details page, with the Register RentalOS confirmation modal opened automatically. The focus value is preserved across the unauthenticated /sign-in redirect, the OAuth round-trip, and the post-login /app redirect via sessionStorage. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: wrap home pages in Suspense for useSearchParams CSR bailout Adding useSearchParams to the home View triggered a Next.js "missing-suspense-with-csr-bailout" prerender error on /app and /. Wrap the View in on both routes (matches the existing sign-in page pattern). Co-Authored-By: Claude Opus 4.7 (1M context) * fix: import focus constants from server-safe module in middleware Middleware was importing FOCUS_QUERY_PARAM from src/utils/focus.ts, which is marked 'use client'. In production the Edge-Runtime middleware bundle treated the import as a client reference instead of inlining the string, so request.nextUrl.searchParams.get(undefined) returned null and ?focus=rentals_os_signup was dropped on the / -> /sign-in redirect. Split the shared constants into src/utils/focusConstants.ts (no 'use client') and have middleware import from there. The client helpers (saveFocus / getFocus / clearFocus) stay in focus.ts and re-export the constants so existing client call sites are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * link updates * Add Japan * Connection License Msg update * feat(brand): Brand panel on license details (uploads + name) (#274) * feat(brand): Brand panel on license details (uploads + name) Adds a CollapsibleSection to /license/[tokenId]/details/ where dev license owners can set their OEM brand: - name (string, <= 100 chars) - logo (any aspect, used on the auth button + redirect pages) - icon (square, cropped on upload via react-easy-crop; used in the popup chrome + tab favicon) These persist via dev-console-api's new endpoints (DIMO-Network/dimo-developer-console-api#68). The login-with-dimo SDK will fetch them at initializeDimoSDK time so the button auto-themes. How it composes src/services/brand.ts axios wrappers for the three new endpoints — matches app.ts pattern. src/actions/brand.ts 'use server' thin wrappers, matches actions/workspace.ts. components/Brand/Brand.tsx CollapsibleSection. Loads workspace via getWorkspace() then existing brand via fetchMyBrand. Gates edits with useIsLicenseOwner. components/Brand/components/ ImagePicker.tsx File picker + drag-and-drop + preview. For square icon w/ non-SVG, wraps react-easy-crop to produce a 1:1 cropped File via canvas. SVGs pass through unchanged. components/View/View.tsx Mounts next to RedirectUris; adds BrandFragment to the GQL query. Deps + react-easy-crop ^5.5.0 Auth No new pattern — reads and writes ride the existing AuthenticationMiddleware on dev-console-api. Ownership verified server-side as workspace.owner === request.user.user.address. Non-owners see the panel in read-only mode. * feat(brand): primary color picker in Brand panel Adds a hex color input + native picker to the Brand panel on /license/[tokenId]/details/. Validates #RRGGBB format client-side and on the API. A small swatch shows the resolved color next to the input. Color flows through saveMyBrand → console-api → workspace_brands .primary_color. SDK + popup pick it up via their respective brand fetches. * fix(build): install react-easy-crop + regen gql + null-safe tokenId access - Adds react-easy-crop to package-lock.json (Vercel build was missing the dep) - Regenerates src/gql after adding BrandFragment - One pre-existing null-safety hop in layout.tsx surfaced by the codegen rerun: data?.developerLicense.tokenId -> data?.developerLicense?.tokenId * fix(brand): require display name in panel SDK and popup both gate brand load on body.name — an empty name causes the entire brand (logo, icon, color) to be silently dropped on the consumer side. Make the form refuse to submit without a name so users aren't surprised when their uploaded logo never appears. * fix(brand): allow IPFS gateway + blob/data URIs in img-src CSP Brand panel renders the uploaded logo/icon via and the live preview via URL.createObjectURL (blob: URLs). Default CSP only allowed 'self' + walletconnect — both would have been silently blocked, so the user would upload a logo and see nothing on the next render. * fix(brand): strip dead WalletConnect entries from CSP Console uses Turnkey + ZeroDev for auth; wagmi is only used for HTTP transports + chain defs, no WC connector is configured. Verified runtime: Vercel preview sign-in page settles with zero requests to explorer-api.walletconnect.com or pulse.walletconnect.org. CSP was allowlisting endpoints we never call — removed both from connect-src and img-src to shrink the attack surface and stop signalling that the codebase uses WC. * chore: revert unrelated .gitignore change CLAUDE.md gitignore entry slipped into this PR but is not related to the brand work. Belongs in a separate housekeeping change. * fix(brand): address code review issues before merge - Color picker: replace reset() with setValue(..., { shouldDirty: true }) so the form isDirty flag stays correct and other fields aren't re-validated - CSP img-src: remove `data:` — image previews use blob: URLs (URL.createObjectURL), not data URIs - Add setValue to useForm destructure (required by the above fix) - services/brand: handle 404 from API (API now returns 404 when no brand exists, previously returned {}); import isAxiosError for the guard Co-Authored-By: Claude Sonnet 4.6 * fix(brand): handle 404 in getMyBrand (API now returns 404 not {}) API endpoint GET /my/workspace/:id/brand was updated to return 404 when no brand exists instead of {}. Update the service to catch 404 and return null instead of trying to distinguish {} from a valid response. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: James Li Co-authored-by: Claude Sonnet 4.6 * fix(brand): surface workspace-load failures instead of silent no-op (#275) When getWorkspace() returns no id (expired session, no company workspace yet) the load effect was returning silently — setLoading(false) still ran via finally, so the form rendered, but workspaceId stayed null and every Save click hit the early-return guard with zero user feedback. Notify on both code paths: - load(): if !ws?.id, show 'Workspace not found' toast + Sentry message - onSubmit(): if !workspaceId, show 'Cannot save' toast instead of return - load() catch: surface a generic 'Brand load error' toast Diagnostic for users currently seeing 'Save Brand does nothing' on console.dimo.org — the toast will name which step failed. * fix(brand): stop toast loop on workspace-load failure (#276) dae756c added setNotification to the load effect's deps to surface the silent no-op. But NotificationContext's setNotification reference is not stable across renders — each toast caused a re-render that flipped the dep, which re-ran the effect, which fetched + failed + toasted again. Infinite loop of "Could not load your workspace" toasts on any account whose getWorkspace() returns empty. Two changes: - load effect runs once (empty deps) via a ref-pinned setNotification - error message goes into a loadError state slot, toasted by a second effect keyed only on loadError so re-renders can't refire the fetch setNotification used in onSubmit is unchanged — that path runs on user click, not in a render cycle. * fix(brand): resolve workspace by license tokenId (#277) Companion to DIMO-Network/dimo-developer-console-api#69. The Brand panel is per-license, so the URL already pins the right workspace — switch the load effect to use the new /api/my/workspace/by-token/:tokenId endpoint and keep the company-scoped lookup only as a fallback. Fixes the silent 'Save Brand does nothing' bug on accounts whose workspace.company_id has drifted from the user's current company. * content type fix * NextJs config * docs: add multi-brand per developer license design spec Co-Authored-By: Claude Sonnet 4.6 * feat(brand): extend service + action layers for multi-brand collection Co-Authored-By: Claude Sonnet 4.6 * feat(brand): add BrandRow component with default badge and owner-gated actions Co-Authored-By: Claude Sonnet 4.6 * feat(brand): add BrandForm component for create/edit with rename warning and set-default Co-Authored-By: Claude Sonnet 4.6 * feat(brand): refactor Brand panel into multi-brand list manager with SDK hint Co-Authored-By: Claude Sonnet 4.6 * chore: update InputMaybe type to include undefined Co-Authored-By: Claude Sonnet 4.6 * fix(brand): disable delete button when only one brand or brand is default canDelete was inverted - was allowing delete of the only brand (which the API rejects anyway with 409). Now correctly disabled unless there are multiple brands AND this one is not the default. Co-Authored-By: Claude Sonnet 4.6 * feat(configurator): add brandName selector to Login with DIMO config When a workspace has multiple brands, the Login with DIMO configuration form now shows a brand dropdown populated from the configured brands. Selecting a brand emits brandName in the generated code snippet and URL. Leaving it as 'Default' omits the prop, matching existing single-brand behaviour. Co-Authored-By: Claude Sonnet 4.6 * feat(configurator): add brandName selector to Share Vehicles with DIMO section Mirrors the Login with DIMO brand dropdown — shows when workspace has multiple named brands, omitted for single-brand workspaces. Co-Authored-By: Claude Sonnet 4.6 * feat(connections): grant SACD permissions to generated connection keys (#279) * feat(connections): grant SACD permissions to generated connection keys The connection-create flow generated a Connection License key and a Device Issuance key but never granted them any on-chain permissions, so neither could actually be used to generate certificates or mint synthetic devices. After the mint, the flow now reads connectionId from the ConnectionMinted event and calls setPermissions on the SACD contract for each grantee: permission 1 (MintSD, bitmask 12) for the device-issuance key, permission 2 (GenerateCertificate, bitmask 48) for the license key. The unused Kernel AA wrapping is dropped — the grantee is the raw EOA derived from each generated private key. The loading modal now describes what each signature is for instead of a single generic label. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(connections): show device issuance address on connection details Derive and display the EOA address for the Device Issuance Key so users can reference it without unmasking the private key. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update codegen'd GraphQL types Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * Correct more connection mint prices 100 to 1 * docs: add design spec for renounce vehicle permissions feature Captures architecture, data flow, component design, error handling, and testing plan for the per-vehicle renounce action on the vehicle list table. Also removes unused axios import from brand.ts that was blocking the pre-commit lint hook. Co-Authored-By: Claude Sonnet 4.6 * feat(vehicles): add renounce vehicle access action - useRenounceVehiclePermissions hook: calls SACD setPermissions with permissions=0 and expiration=0 to zero out developer's access - RenounceVehicleModal: confirmation modal with vehicle MMY, consequence copy, inline error on failure, loading state during tx - VehicleDetailsTable: inline kebab menu per row opens modal; optimistic removal from list on success + background refetch; success/error toasts via NotificationContext; Sentry capture on error - Tests: 14 passing across hook unit and modal unit test suites Co-Authored-By: Claude Sonnet 4.6 * fix(vehicles): restore exact gql query string for TypedDocumentNode inference Trailing whitespace in the query must match the codegen-registered string exactly or gql() returns unknown instead of TypedDocumentNode. Co-Authored-By: Claude Sonnet 4.6 * fix(vehicles): use renouncePermissions instead of setPermissions The SACD contract only allows the grantor to call setPermissions — grantees get Unauthorized(address). The contract exposes a dedicated renouncePermissions(asset, tokenId) function (0x290a7e7e) where msg.sender is treated as the grantee, allowing developers to self-revoke access. Added renouncePermissions to Sacd.json ABI and updated hook accordingly. Removed unused currentUser dependency from the hook. Co-Authored-By: Claude Sonnet 4.6 * fix(vehicles): throw on success:false receipt from processTransactions processTransactions only throws on HttpRequestError or a non-empty receipt.reason — a failed transaction with success:false and no reason string was silently returning, causing the UI to show success while the on-chain renounce never executed. Co-Authored-By: Claude Sonnet 4.6 * debug(vehicles): log clientId vs smartContractAddress in renounce Surfaces whether the SACD grantee (clientId) matches msg.sender (smartContractAddress) — a mismatch would explain silent no-ops. Also logs the full processTransactions result including tx hash. Co-Authored-By: Claude Sonnet 4.6 * fix(test): pass clientId arg to renounce calls Co-Authored-By: Claude Sonnet 4.6 * docs: add frontend redesign design spec DIMO Teal direction, dark/light mode via next-themes + class strategy, collapsible grouped sidebar, shadcn/ui component migration plan. Co-Authored-By: Claude Sonnet 4.6 * docs: add frontend redesign implementation plan 10-task foundation-first plan: token layer, theme toggle, sidebar collapse, shadcn/ui migration, Sonner, AppCard simplification. Co-Authored-By: Claude Sonnet 4.6 * feat: add DIMO Teal token layer and shadcn/ui foundation Installs next-themes, lucide-react, clsx, tailwind-merge, and shadcn/ui. Sets up DIMO Teal HSL CSS variables for light/dark modes, updates Tailwind config with darkMode class + token colors, and adds src/lib/utils.ts with cn(). Co-Authored-By: Claude Sonnet 4.6 * feat: add dark/light mode toggle via next-themes Co-Authored-By: Claude Sonnet 4.6 * fix: make ThemeToggle keyboard accessible, fix hydration fallback size Replace div[role=switch] with a native + + `; diff --git a/__tests__/unit/components/__snapshots__/Table.test.tsx.snap b/__tests__/unit/components/__snapshots__/Table.test.tsx.snap index 969e2f00..9aae55e4 100644 --- a/__tests__/unit/components/__snapshots__/Table.test.tsx.snap +++ b/__tests__/unit/components/__snapshots__/Table.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Table renders a table with actions 1`] = `
@@ -56,7 +57,7 @@ exports[`Table renders a table with actions 1`] = ` exports[`Table renders a table with custom render 1`] = `
-
-
-
-

- Success information -

-
-
- -
-
-
- -`; - -exports[`Toast renders an error notification 1`] = ` -
-
-
-
-

- Error information -

-
-
- -
-
-
-
-`; diff --git a/__tests__/unit/components/__snapshots__/Toggle.test.tsx.snap b/__tests__/unit/components/__snapshots__/Toggle.test.tsx.snap index e33173a1..242932aa 100644 --- a/__tests__/unit/components/__snapshots__/Toggle.test.tsx.snap +++ b/__tests__/unit/components/__snapshots__/Toggle.test.tsx.snap @@ -5,29 +5,15 @@ exports[`Toggle renders a toggle component 1`] = ` diff --git a/__tests__/unit/configurator/ConfigurationList.test.tsx b/__tests__/unit/configurator/ConfigurationList.test.tsx new file mode 100644 index 00000000..d0599e65 --- /dev/null +++ b/__tests__/unit/configurator/ConfigurationList.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ConfigurationList } from '@/app/license/[tokenId]/configurator/components/ConfigurationList'; +import * as configurationsActions from '@/actions/configurations'; + +jest.mock('@/actions/configurations', () => ({ + getConfigurationsByClientId: jest.fn(), + deleteConfiguration: jest.fn(), +})); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +const mockConfigs = [ + { id: 'abc123', configuration_name: 'My Config', entry_state: 'VEHICLE_MANAGER' }, + { id: 'def456', configuration_name: 'Another Config', entry_state: 'EMAIL_INPUT' }, +]; + +describe('ConfigurationList', () => { + beforeEach(() => { + jest.clearAllMocks(); + (configurationsActions.getConfigurationsByClientId as jest.Mock).mockResolvedValue( + mockConfigs, + ); + }); + + it('renders a list of configurations', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + expect(screen.getByText('Another Config')).toBeInTheDocument(); + }); + }); + + it('renders empty state when no configurations exist', async () => { + (configurationsActions.getConfigurationsByClientId as jest.Mock).mockResolvedValue( + [], + ); + render(); + await waitFor(() => { + expect(screen.getByText(/no configurations yet/i)).toBeInTheDocument(); + }); + }); + + it('shows confirm UI when delete is clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('calls deleteConfiguration and refreshes on confirm', async () => { + (configurationsActions.deleteConfiguration as jest.Mock).mockResolvedValue(undefined); + (configurationsActions.getConfigurationsByClientId as jest.Mock) + .mockResolvedValueOnce(mockConfigs) + .mockResolvedValueOnce([mockConfigs[1]]); + render(); + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + await waitFor(() => { + expect(configurationsActions.deleteConfiguration).toHaveBeenCalledWith({ + id: 'abc123', + }); + }); + }); + + it('cancels delete when cancel is clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/unit/hoc/withLayout.test.tsx b/__tests__/unit/hoc/withLayout.test.tsx new file mode 100644 index 00000000..967f3bff --- /dev/null +++ b/__tests__/unit/hoc/withLayout.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useContext } from 'react'; +import { LayoutContext } from '@/context/LayoutContext'; +import { withLayout } from '@/hoc/withLayout'; + +const TestConsumer = () => { + const { isSidebarCollapsed, setSidebarCollapsed } = useContext(LayoutContext); + return ( +
+ {isSidebarCollapsed ? 'collapsed' : 'expanded'} + +
+ ); +}; + +const Wrapped = withLayout(TestConsumer); + +describe('withLayout', () => { + it('provides isSidebarCollapsed defaulting to false', () => { + render(); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + }); + + it('setSidebarCollapsed updates state', () => { + render(); + fireEvent.click(screen.getByText('collapse')); + expect(screen.getByTestId('state').textContent).toBe('collapsed'); + }); +}); diff --git a/__tests__/unit/pages/app/details/[id]/__snapshots__/SignerList.test.tsx.snap b/__tests__/unit/pages/app/details/[id]/__snapshots__/SignerList.test.tsx.snap deleted file mode 100644 index 107a6f1d..00000000 --- a/__tests__/unit/pages/app/details/[id]/__snapshots__/SignerList.test.tsx.snap +++ /dev/null @@ -1,144 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SignerList renders the app summary 1`] = ` -
-
- - - - - - - - - - - - - - - - -
- API Key - - - Actions - -
-

- abcdef12345abcdef12345abcdef12345abcdef12345 - - - -

-
- - -
-

- abcdef12345abcdef12345abcdef12345abcdef12345 - - - -

-
- - -
-
-`; diff --git a/__tests__/unit/pages/license/[tokenId]/details/Brand.test.tsx b/__tests__/unit/pages/license/[tokenId]/details/Brand.test.tsx new file mode 100644 index 00000000..cc94e4fb --- /dev/null +++ b/__tests__/unit/pages/license/[tokenId]/details/Brand.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Brand } from '@/app/license/[tokenId]/details/components/Brand/Brand'; + +jest.mock('@/components/CollapsibleSection', () => ({ + CollapsibleSection: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Attach sub-components so Brand can use CollapsibleSection.Title and .Content +const { CollapsibleSection } = jest.requireMock('@/components/CollapsibleSection'); +/* eslint-disable react/display-name */ +CollapsibleSection.Title = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); +CollapsibleSection.Content = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); +/* eslint-enable react/display-name */ + +jest.mock('@/gql', () => ({ + gql: (s: TemplateStringsArray) => s, + useFragment: (_def: unknown, data: unknown) => data, +})); + +jest.mock('@/hooks/useIsLicenseOwner', () => ({ + useIsLicenseOwner: () => true, +})); + +jest.mock('@/actions/workspace', () => ({ + getWorkspace: jest.fn().mockResolvedValue({ id: 'ws-1' }), + getWorkspaceByTokenId: jest.fn().mockResolvedValue({ id: 'ws-1' }), +})); + +jest.mock('@/actions/brand', () => ({ + fetchMyBrands: jest.fn(), + deleteMyBrand: jest.fn().mockResolvedValue(undefined), +})); + +import { fetchMyBrands } from '@/actions/brand'; + +const mockLicense = { owner: '0xOwner', tokenId: 42, clientId: '0xClient' }; + +const fleetBrand = { + id: 'b1', + name: 'Fleet App', + isDefault: true, + logoCid: null, + iconCid: null, + logoUrl: null, + iconUrl: null, + primaryColor: null, + updatedAt: null, +}; + +describe('Brand', () => { + afterEach(() => jest.clearAllMocks()); + + it('shows loading state initially', () => { + (fetchMyBrands as jest.Mock).mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading brands…')).toBeInTheDocument(); + }); + + it('shows empty state when workspace has no brands', async () => { + (fetchMyBrands as jest.Mock).mockResolvedValue([]); + render(); + await waitFor(() => expect(screen.getByText('No brand set.')).toBeInTheDocument()); + }); + + it('renders a row for each loaded brand', async () => { + (fetchMyBrands as jest.Mock).mockResolvedValue([ + fleetBrand, + { ...fleetBrand, id: 'b2', name: 'Consumer App', isDefault: false }, + ]); + render(); + await waitFor(() => expect(screen.getByText('Fleet App')).toBeInTheDocument()); + expect(screen.getByText('Consumer App')).toBeInTheDocument(); + }); + + it('shows Add Brand button for owner', async () => { + (fetchMyBrands as jest.Mock).mockResolvedValue([]); + render(); + await waitFor(() => expect(screen.getByText('Add Brand')).toBeInTheDocument()); + }); + + it('shows BrandForm when Add Brand is clicked', async () => { + (fetchMyBrands as jest.Mock).mockResolvedValue([]); + render(); + await waitFor(() => fireEvent.click(screen.getByText('Add Brand'))); + expect(screen.getByPlaceholderText('e.g. Fleet App')).toBeInTheDocument(); + }); + + it('shows SDK hint panel when brands exist', async () => { + (fetchMyBrands as jest.Mock).mockResolvedValue([fleetBrand]); + render(); + await waitFor(() => + expect( + screen.getByText('Using multiple brands with Login with DIMO'), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/__tests__/unit/pages/license/[tokenId]/details/BrandForm.test.tsx b/__tests__/unit/pages/license/[tokenId]/details/BrandForm.test.tsx new file mode 100644 index 00000000..5e1d4c79 --- /dev/null +++ b/__tests__/unit/pages/license/[tokenId]/details/BrandForm.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { BrandForm } from '@/app/license/[tokenId]/details/components/Brand/components/BrandForm'; +import type { BrandView } from '@/services/brand'; + +jest.mock('@/actions/brand', () => ({ + createMyBrand: jest.fn().mockResolvedValue({ + id: 'new-1', + name: 'New Brand', + logoCid: null, + iconCid: null, + logoUrl: null, + iconUrl: null, + primaryColor: null, + isDefault: false, + updatedAt: null, + }), + updateMyBrandById: jest.fn().mockResolvedValue({ + id: 'b1', + name: 'Fleet App', + logoCid: null, + iconCid: null, + logoUrl: null, + iconUrl: null, + primaryColor: null, + isDefault: true, + updatedAt: null, + }), + setDefaultBrand: jest.fn().mockResolvedValue({}), + uploadMyBrandAsset: jest.fn(), +})); + +const existingBrand: BrandView = { + id: 'b1', + name: 'Fleet App', + logoCid: null, + iconCid: null, + logoUrl: null, + iconUrl: null, + primaryColor: null, + isDefault: true, + updatedAt: null, +}; + +const nonDefaultBrand: BrandView = { + ...existingBrand, + id: 'b2', + name: 'Consumer App', + isDefault: false, +}; + +describe('BrandForm', () => { + it('renders the name field pre-filled for an existing brand', () => { + render( + , + ); + expect(screen.getByDisplayValue('Fleet App')).toBeInTheDocument(); + }); + + it('renders an empty name field when creating a new brand', () => { + render( + , + ); + expect(screen.getByPlaceholderText('e.g. Fleet App')).toHaveValue(''); + }); + + it('shows rename warning when name is changed on an existing brand', async () => { + render( + , + ); + fireEvent.change(screen.getByDisplayValue('Fleet App'), { + target: { value: 'Fleet App 2' }, + }); + await waitFor(() => { + expect( + screen.getByText(/Renaming breaks existing Login with DIMO calls/), + ).toBeInTheDocument(); + }); + }); + + it('does not show rename warning when creating a new brand', () => { + render( + , + ); + fireEvent.change(screen.getByPlaceholderText('e.g. Fleet App'), { + target: { value: 'New Name' }, + }); + expect(screen.queryByText(/Renaming breaks/)).not.toBeInTheDocument(); + }); + + it('shows Set as Default button for a non-default brand', () => { + render( + , + ); + expect(screen.getByText('Set as Default')).toBeInTheDocument(); + }); + + it('does not show Set as Default button for the default brand', () => { + render( + , + ); + expect(screen.queryByText('Set as Default')).not.toBeInTheDocument(); + }); + + it('calls onCancel when Cancel is clicked', () => { + const onCancel = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/unit/pages/license/[tokenId]/details/BrandRow.test.tsx b/__tests__/unit/pages/license/[tokenId]/details/BrandRow.test.tsx new file mode 100644 index 00000000..0ad4aa0f --- /dev/null +++ b/__tests__/unit/pages/license/[tokenId]/details/BrandRow.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BrandRow } from '@/app/license/[tokenId]/details/components/Brand/components/BrandRow'; +import type { BrandView } from '@/services/brand'; + +const baseBrand: BrandView = { + id: 'b1', + name: 'Fleet App', + logoCid: null, + iconCid: null, + logoUrl: null, + iconUrl: null, + primaryColor: null, + isDefault: true, + updatedAt: null, +}; + +const nonDefault: BrandView = { + ...baseBrand, + id: 'b2', + name: 'Consumer App', + isDefault: false, +}; + +describe('BrandRow', () => { + it('shows the brand name', () => { + render( + , + ); + expect(screen.getByText('Fleet App')).toBeInTheDocument(); + }); + + it('shows Default badge when isDefault is true', () => { + render( + , + ); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('does not show Default badge when isDefault is false', () => { + render( + , + ); + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + }); + + it('calls onEdit when edit button is clicked', () => { + const onEdit = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByTitle('Edit brand')); + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + it('disables delete when brand is default and multiple brands exist', () => { + render( + , + ); + expect( + screen.getByTitle('Set another brand as default before deleting this one'), + ).toBeDisabled(); + }); + + it('enables delete on default brand when it is the only brand', () => { + const onDelete = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByTitle('Delete brand')); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('calls onDelete on a non-default brand', () => { + const onDelete = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByTitle('Delete brand')); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('hides action buttons when not owner', () => { + render( + , + ); + expect(screen.queryByTitle('Edit brand')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Delete brand')).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/unit/pages/license/vehicles/[clientId]/RenounceVehicleModal.test.tsx b/__tests__/unit/pages/license/vehicles/[clientId]/RenounceVehicleModal.test.tsx new file mode 100644 index 00000000..70777494 --- /dev/null +++ b/__tests__/unit/pages/license/vehicles/[clientId]/RenounceVehicleModal.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { RenounceVehicleModal } from '@/app/license/vehicles/[clientId]/components/RenounceVehicleModal'; + +const vehicleWithMMY = { + tokenId: 42, + definition: { make: 'Tesla', model: 'Model 3', year: 2022 }, +}; + +const vehicleNoDefinition = { + tokenId: 99, + definition: null, +}; + +describe('RenounceVehicleModal', () => { + it('renders nothing when vehicle is null', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows vehicle MMY when definition is present', () => { + render( + , + ); + expect(screen.getByText('Tesla Model 3 2022')).toBeInTheDocument(); + expect(screen.getByText('Token ID: 42')).toBeInTheDocument(); + }); + + it('falls back to Token # label when no definition', () => { + render( + , + ); + expect(screen.getByText('Token #99')).toBeInTheDocument(); + }); + + it('shows consequence copy', () => { + render( + , + ); + expect( + screen.getByText(/You will lose all data access to this vehicle/), + ).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', () => { + const onClose = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onConfirm when Renounce access is clicked', async () => { + const onConfirm = jest.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.click(screen.getByText('Renounce access')); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + }); + + it('shows inline error when onConfirm rejects', async () => { + const onConfirm = jest.fn().mockRejectedValue(new Error('Transaction reverted')); + render( + , + ); + fireEvent.click(screen.getByText('Renounce access')); + await waitFor(() => + expect(screen.getByText('Transaction reverted')).toBeInTheDocument(), + ); + }); + + it('keeps modal open after error', async () => { + const onConfirm = jest.fn().mockRejectedValue(new Error('fail')); + const onClose = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Renounce access')); + await waitFor(() => expect(screen.getByText('fail')).toBeInTheDocument()); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/unit/pages/license/vehicles/[clientId]/useRenounceVehiclePermissions.test.ts b/__tests__/unit/pages/license/vehicles/[clientId]/useRenounceVehiclePermissions.test.ts new file mode 100644 index 00000000..d97f59b1 --- /dev/null +++ b/__tests__/unit/pages/license/vehicles/[clientId]/useRenounceVehiclePermissions.test.ts @@ -0,0 +1,116 @@ +import { renderHook, act } from '@testing-library/react'; + +// Mock viem before hook import so encodeFunctionData doesn't validate addresses +jest.mock('viem', () => ({ + ...jest.requireActual('viem'), + encodeFunctionData: jest.fn().mockReturnValue('0xencoded'), +})); + +const mockProcessTransactions = jest.fn(); + +jest.mock('@/hooks/useContractGA', () => ({ + useContractGA: () => ({ processTransactions: mockProcessTransactions }), +})); + +jest.mock('@/config', () => ({ + __esModule: true, + default: { + DIMO_SACD_ADDRESS: '0x5ACD000000000000000000000000000000000001', + VEHICLE_NFT_ADDRESS: '0xBEEF000000000000000000000000000000000001', + }, +})); + +import { useRenounceVehiclePermissions } from '@/hooks/useRenounceVehiclePermissions'; +import { encodeFunctionData } from 'viem'; + +describe('useRenounceVehiclePermissions', () => { + beforeEach(() => { + mockProcessTransactions.mockReset(); + (encodeFunctionData as jest.Mock).mockReset(); + (encodeFunctionData as jest.Mock).mockReturnValue('0xencoded'); + }); + + it('calls processTransactions targeting the SACD contract', async () => { + mockProcessTransactions.mockResolvedValue({ success: true }); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await act(async () => { + await result.current.renounce(42, '0xCLIENT'); + }); + + expect(mockProcessTransactions).toHaveBeenCalledTimes(1); + const [txs] = mockProcessTransactions.mock.calls[0]; + expect(txs[0].to).toBe('0x5ACD000000000000000000000000000000000001'); + expect(txs[0].value).toBe(BigInt(0)); + expect(txs[0].data).toBe('0xencoded'); + }); + + it('encodes renouncePermissions with asset and tokenId', async () => { + mockProcessTransactions.mockResolvedValue({ success: true }); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await act(async () => { + await result.current.renounce(42, '0xCLIENT'); + }); + + expect(encodeFunctionData).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'renouncePermissions', + args: ['0xBEEF000000000000000000000000000000000001', BigInt(42)], + }), + ); + }); + + it('throws when processTransactions returns success: false with a reason', async () => { + mockProcessTransactions.mockResolvedValue({ success: false, reason: 'reverted' }); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await expect( + act(async () => { + await result.current.renounce(42, '0xCLIENT'); + }), + ).rejects.toThrow('reverted'); + }); + + it('throws with fallback message when success: false and no reason', async () => { + mockProcessTransactions.mockResolvedValue({ success: false, reason: undefined }); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await expect( + act(async () => { + await result.current.renounce(42, '0xCLIENT'); + }), + ).rejects.toThrow('Transaction failed'); + }); + + it('isLoading is false initially', () => { + const { result } = renderHook(() => useRenounceVehiclePermissions()); + expect(result.current.isLoading).toBe(false); + }); + + it('isLoading returns to false after success', async () => { + mockProcessTransactions.mockResolvedValue({ success: true }); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await act(async () => { + await result.current.renounce(42, '0xCLIENT'); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('isLoading returns to false after failure', async () => { + mockProcessTransactions.mockRejectedValue(new Error('reverted')); + const { result } = renderHook(() => useRenounceVehiclePermissions()); + + await act(async () => { + try { + await result.current.renounce(42, '0xCLIENT'); + } catch { + // expected + } + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/components.json b/components.json new file mode 100644 index 00000000..7b17557f --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/docs/superpowers/plans/2026-04-14-multi-configuration.md b/docs/superpowers/plans/2026-04-14-multi-configuration.md new file mode 100644 index 00000000..570da292 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-multi-configuration.md @@ -0,0 +1,1343 @@ +# Multi-Configuration Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow one developer license to have multiple saved configurator configurations, replacing the 1:1 constraint with a list page → create/edit flow. + +**Architecture:** `/configurator` becomes a list page; `/configurator/new` is the create form (moved from the current `/configurator`); `/configurator/[id]` (edit) is unchanged in behavior. Shared form components are consolidated under `configurator/components/ConfigurationForm/` and the `[id]` directory imports from there. The backend prerequisite (see Task 0) must be complete before tasks that fetch the list or call delete. + +**Tech Stack:** Next.js App Router, TypeScript, React Hook Form, Apollo Client, Jest + React Testing Library + +--- + +## Prerequisites + +**Task 0 (backend, separate repo `dimo-developer-console-api`):** + +1. `GET /api/my/configurations?clientId=...` must return `{ id, configuration_name, entry_state }[]` — an array, not a single object. `entry_state` is the existing value already stored on the configuration (e.g. `EMAIL_INPUT`, `VEHICLE_MANAGER`, `ADVANCED_TRANSACTION`). +2. `DELETE /api/my/configurations/:id` must be added, scoped to the authenticated user. + +These tasks are written assuming the backend is done. Front-end tasks can be implemented and merged in any order, but the list page will show an empty state until the backend is live. + +--- + +## File Map + +| File | Action | Purpose | +| ----------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------- | +| `src/actions/configurations.ts` | Modify | Rename list action, add delete action | +| `src/app/license/[tokenId]/configurator/components/ConfigurationForm/types.ts` | Modify | Add `configuration_id` to `SharedProps` | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ConfigurationForm.tsx` | Modify | Import types + sub-components from shared location | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/types.ts` | Delete | Replaced by shared types.ts | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/LoginWithDimoConfiguration.tsx` | Delete | Replaced by shared component | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ShareVehiclesWithDimoConfiguration.tsx` | Delete | Replaced by shared component | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ExecuteAdvanceTransactionWithDimoConfiguration.tsx` | Delete | Replaced by shared component | +| `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/index.ts` | Modify | Remove re-exports that now live in shared location | +| `src/app/license/[tokenId]/configurator/[id]/components/View/View.tsx` | Modify | Import types from shared location | +| `src/app/license/[tokenId]/configurator/components/ConfigurationList/ConfigurationList.tsx` | Create | New list UI component | +| `src/app/license/[tokenId]/configurator/components/ConfigurationList/index.ts` | Create | Barrel export | +| `src/app/license/[tokenId]/configurator/components/View/View.tsx` | Delete | Moved to `/new` route | +| `src/app/license/[tokenId]/configurator/components/View/index.ts` | Delete | No longer needed | +| `src/app/license/[tokenId]/configurator/page.tsx` | Modify | Becomes list page | +| `src/app/license/[tokenId]/configurator/new/page.tsx` | Create | Create configuration page | +| `src/app/license/[tokenId]/configurator/new/components/View/View.tsx` | Create | Create configuration view (moved + redirect changed) | +| `src/app/license/[tokenId]/configurator/new/components/View/index.ts` | Create | Barrel export | +| `src/app/license/[tokenId]/details/components/Vehicles/Vehicles.tsx` | Modify | Remove configurationId fetch, simplify button | +| `__tests__/unit/configurator/ConfigurationList.test.tsx` | Create | Tests for list component | + +--- + +## Task 1: Update `src/actions/configurations.ts` + +**Files:** + +- Modify: `src/actions/configurations.ts` + +- [ ] **Step 1: Replace the file contents** + +```typescript +'use server'; + +import { dimoDevAPIClient } from '@/services/dimoDevAPI'; + +export interface IConfiguration { + id: string; + configuration_name: string; + configuration: Record; +} + +export interface IConfigurationListItem { + id: string; + configuration_name: string; + entry_state: string; +} + +export const getConfigurationsByClientId = async ({ + client_id, +}: { + client_id: string; +}): Promise => { + try { + const client = await dimoDevAPIClient(); + const { data } = await client.get( + `/api/my/configurations?clientId=${client_id}`, + ); + return data; + } catch { + return []; + } +}; + +export const getConfiguration = async ({ + id, +}: { + id: string; +}): Promise => { + const client = await dimoDevAPIClient(); + const { data } = await client.get<{ configuration: IConfiguration }>( + `/api/my/configurations/${id}`, + ); + return data.configuration; +}; + +export const saveConfiguration = async ({ + client_id, + configuration_name, + configuration, +}: { + client_id: string; + configuration_name: string; + configuration: Record; +}): Promise<{ id: string }> => { + const client = await dimoDevAPIClient(); + const { data } = await client.post(`/api/my/configurations`, { + client_id, + configuration_name, + configuration, + }); + + return { id: data.id }; +}; + +export const updateConfiguration = async ({ + id, + client_id, + configuration_name, + configuration, +}: { + id: string; + client_id: string; + configuration_name: string; + configuration: Record; +}) => { + const client = await dimoDevAPIClient(); + await client.put(`/api/my/configurations/${id}`, { + client_id, + configuration_name, + configuration, + }); +}; + +export const deleteConfiguration = async ({ id }: { id: string }) => { + const client = await dimoDevAPIClient(); + await client.delete(`/api/my/configurations/${id}`); +}; +``` + +- [ ] **Step 2: Run TypeScript check to catch any reference to the old `getConfigurationByClientId`** + +```bash +npm run compile 2>&1 | grep -i "getConfigurationByClientId\|configurations" +``` + +Expected: errors pointing to any remaining callers of the old name (will fix in Task 5 for Vehicles.tsx). + +- [ ] **Step 3: Commit** + +```bash +git add src/actions/configurations.ts +git commit -m "feat: rename getConfigurationsByClientId to return array, add deleteConfiguration" +``` + +--- + +## Task 2: Consolidate shared form types + +The `[id]` `types.ts` has `configuration_id` in `SharedProps`; the shared one at `configurator/components/ConfigurationForm/types.ts` does not. We add `configuration_id` to the shared file so both the create form (where it will be empty/unused) and edit form use the same type. + +**Files:** + +- Modify: `src/app/license/[tokenId]/configurator/components/ConfigurationForm/types.ts` + +- [ ] **Step 1: Add `configuration_id` to `SharedProps`** + +Open `src/app/license/[tokenId]/configurator/components/ConfigurationForm/types.ts`. Change: + +```typescript +interface SharedProps { + client_id: string; + configuration_name: string; + redirectUri: string; +``` + +To: + +```typescript +interface SharedProps { + client_id: string; + configuration_name: string; + configuration_id: string; + redirectUri: string; +``` + +The rest of the file is unchanged. + +- [ ] **Step 2: Run TypeScript check** + +```bash +npm run compile 2>&1 | grep -i "types\|SharedProps" +``` + +Expected: no new errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/license/[tokenId]/configurator/components/ConfigurationForm/types.ts +git commit -m "feat: add configuration_id to shared configurator types" +``` + +--- + +## Task 3: Update `[id]` ConfigurationForm to import from shared components + +The `[id]/components/ConfigurationForm/ConfigurationForm.tsx` currently imports its sub-components (LoginWithDimoConfiguration, ShareVehiclesWithDimoConfiguration, etc.) and types from its own local directory. We point these at the shared `configurator/components/ConfigurationForm/` location and then delete the redundant local copies. + +**Files:** + +- Modify: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ConfigurationForm.tsx` +- Delete: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/types.ts` +- Delete: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/LoginWithDimoConfiguration.tsx` +- Delete: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ShareVehiclesWithDimoConfiguration.tsx` +- Delete: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/ExecuteAdvanceTransactionWithDimoConfiguration.tsx` +- Modify: `src/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/index.ts` +- Modify: `src/app/license/[tokenId]/configurator/[id]/components/View/View.tsx` + +- [ ] **Step 1: Rewrite `[id]/components/ConfigurationForm/ConfigurationForm.tsx`** + +Replace the file with these updated imports (all other logic stays identical): + +```typescript +import { FC, useContext } from 'react'; +import { FragmentType, useFragment } from '@/gql'; +import { Label } from '@/components/Label'; +import { SelectField } from '@/components/SelectField'; +import { Control, UseFormRegister, useFormContext } from 'react-hook-form'; +import { LoginWithDimoConfiguration } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/LoginWithDimoConfiguration'; +import { ShareVehiclesWithDimoConfiguration } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/ShareVehiclesWithDimoConfiguration'; +import { ExecuteAdvanceTransactionWithDimoConfiguration } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/ExecuteAdvanceTransactionWithDimoConfiguration'; +import { + DynamicFormProps, + ComponentType, +} from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/types'; +import { SegmentedControl } from '@/components/SegmentedControl'; +import { TextField } from '@/components/TextField'; +import { USER_CONFIG_FRAGMENT } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm'; +import { Button } from '@/components/Button'; +import configuration from '@/config'; +import { NotificationContext } from '@/context/notificationContext'; + +interface Props { + license: FragmentType; + submit: (data: DynamicFormProps) => void; +} + +interface IFormProps { + component: ComponentType; + register: UseFormRegister; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: Control; +} + +const Configuration: FC = ({ component, control, register }: IFormProps) => { + switch (component) { + case 'LoginWithDimo': + return ; + case 'ShareVehiclesWithDimo': + return ; + case 'ExecuteAdvancedTransactionWithDimo': + return ( + + ); + default: + return <>; + } +}; + +export const ConfigurationForm: FC = ({ license, submit }) => { + const fragment = useFragment(USER_CONFIG_FRAGMENT, license); + const { setNotification } = useContext(NotificationContext); + + const { register, control, watch, handleSubmit } = useFormContext(); + const component = watch('component', 'ShareVehiclesWithDimo'); + const configurationId = watch('configuration_id'); + + const getBaseUrl = (): string => { + if (configuration.environment === 'production') { + return 'https://login.dimo.org'; + } + return 'https://login.dev.dimo.org'; + }; + + const handleCopyConfigurationLink = () => { + if (!configurationId) { + setNotification('Configuration ID is not available', '', 'error'); + return; + } + const url = `${getBaseUrl()}/?configurationId=${configurationId}`; + navigator.clipboard.writeText(url); + setNotification('Configuration link copied to clipboard', '', 'success'); + }; + + return ( + <> +
+
+ +
+
+ + +
+
+ +
+
+ + +
+ + + + + ); +}; +``` + +- [ ] **Step 2: Delete the now-redundant local copies** + +```bash +rm src/app/license/\[tokenId\]/configurator/\[id\]/components/ConfigurationForm/types.ts +rm src/app/license/\[tokenId\]/configurator/\[id\]/components/ConfigurationForm/LoginWithDimoConfiguration.tsx +rm src/app/license/\[tokenId\]/configurator/\[id\]/components/ConfigurationForm/ShareVehiclesWithDimoConfiguration.tsx +rm src/app/license/\[tokenId\]/configurator/\[id\]/components/ConfigurationForm/ExecuteAdvanceTransactionWithDimoConfiguration.tsx +``` + +- [ ] **Step 3: Update `[id]/components/ConfigurationForm/index.ts` to only re-export `ConfigurationForm`** + +Read the file first, then replace with: + +```typescript +export { ConfigurationForm } from './ConfigurationForm'; +``` + +- [ ] **Step 4: Update `[id]/components/View/View.tsx` — fix the types import** + +In `src/app/license/[tokenId]/configurator/[id]/components/View/View.tsx`, find: + +```typescript +import { + ComponentType, + DynamicFormProps, + PERMISSIONS, +} from '@/app/license/[tokenId]/configurator/[id]/components/ConfigurationForm/types'; +``` + +Replace with: + +```typescript +import { + ComponentType, + DynamicFormProps, + PERMISSIONS, +} from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/types'; +``` + +- [ ] **Step 5: Run TypeScript check and fix any remaining import errors** + +```bash +npm run compile 2>&1 | head -40 +``` + +Expected: no errors. If there are import errors pointing to the deleted files, trace each and update the import path. + +- [ ] **Step 6: Run tests** + +```bash +npm test -- --passWithNoTests +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: consolidate [id] configurator form components to shared location" +``` + +--- + +## Task 4: Build the `ConfigurationList` component + +**Files:** + +- Create: `src/app/license/[tokenId]/configurator/components/ConfigurationList/ConfigurationList.tsx` +- Create: `src/app/license/[tokenId]/configurator/components/ConfigurationList/index.ts` +- Create: `__tests__/unit/configurator/ConfigurationList.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/unit/configurator/ConfigurationList.test.tsx`: + +```typescript +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ConfigurationList } from '@/app/license/[tokenId]/configurator/components/ConfigurationList'; +import * as configurationsActions from '@/actions/configurations'; + +jest.mock('@/actions/configurations', () => ({ + getConfigurationsByClientId: jest.fn(), + deleteConfiguration: jest.fn(), +})); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +const mockConfigs = [ + { id: 'abc123', configuration_name: 'My Config', entry_state: 'VEHICLE_MANAGER' }, + { id: 'def456', configuration_name: 'Another Config', entry_state: 'EMAIL_INPUT' }, +]; + +describe('ConfigurationList', () => { + beforeEach(() => { + jest.clearAllMocks(); + (configurationsActions.getConfigurationsByClientId as jest.Mock).mockResolvedValue( + mockConfigs, + ); + }); + + it('renders a list of configurations', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + expect(screen.getByText('Another Config')).toBeInTheDocument(); + }); + }); + + it('renders empty state when no configurations exist', async () => { + (configurationsActions.getConfigurationsByClientId as jest.Mock).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/no configurations yet/i), + ).toBeInTheDocument(); + }); + }); + + it('shows confirm UI when delete is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('calls deleteConfiguration and refreshes on confirm', async () => { + (configurationsActions.deleteConfiguration as jest.Mock).mockResolvedValue(undefined); + (configurationsActions.getConfigurationsByClientId as jest.Mock) + .mockResolvedValueOnce(mockConfigs) + .mockResolvedValueOnce([mockConfigs[1]]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(configurationsActions.deleteConfiguration).toHaveBeenCalledWith({ + id: 'abc123', + }); + }); + }); + + it('cancels delete when cancel is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('My Config')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + fireEvent.click(deleteButtons[0]); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +npm test -- --testPathPattern="ConfigurationList" --passWithNoTests +``` + +Expected: FAIL — `Cannot find module '@/app/license/[tokenId]/configurator/components/ConfigurationList'` + +- [ ] **Step 3: Create the `ConfigurationList` component** + +Create `src/app/license/[tokenId]/configurator/components/ConfigurationList/ConfigurationList.tsx`: + +```typescript +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + getConfigurationsByClientId, + deleteConfiguration, + IConfigurationListItem, +} from '@/actions/configurations'; +import { Button } from '@/components/Button'; + +interface Props { + clientId: string; + tokenId: number; +} + +const entryStateLabel = (entryState: string): string => { + switch (entryState) { + case 'EMAIL_INPUT': + return 'Login With DIMO'; + case 'VEHICLE_MANAGER': + return 'Share Vehicles With DIMO'; + case 'ADVANCED_TRANSACTION': + return 'Execute Advanced Transaction'; + default: + return entryState; + } +}; + +export const ConfigurationList = ({ clientId, tokenId }: Props) => { + const router = useRouter(); + const [configs, setConfigs] = useState([]); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const load = async () => { + const data = await getConfigurationsByClientId({ client_id: clientId }); + setConfigs(data); + }; + + useEffect(() => { + void load(); + }, [clientId]); + + const handleDelete = async (id: string) => { + await deleteConfiguration({ id }); + setPendingDeleteId(null); + await load(); + }; + + if (configs.length === 0) { + return ( +
+

No configurations yet.

+ +
+ ); + } + + return ( +
+ + + + + + + + + + {configs.map((config) => ( + + + + + + ))} + +
NameComponentActions
{config.configuration_name || '(untitled)'} + {entryStateLabel(config.entry_state)} + + {pendingDeleteId === config.id ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); +}; +``` + +- [ ] **Step 4: Create the barrel export** + +Create `src/app/license/[tokenId]/configurator/components/ConfigurationList/index.ts`: + +```typescript +export { ConfigurationList } from './ConfigurationList'; +``` + +- [ ] **Step 5: Run the test to confirm it passes** + +```bash +npm test -- --testPathPattern="ConfigurationList" +``` + +Expected: all 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/license/\[tokenId\]/configurator/components/ConfigurationList/ __tests__/unit/configurator/ +git commit -m "feat: add ConfigurationList component with edit/delete per row" +``` + +--- + +## Task 5: Replace `configurator/page.tsx` with the list page + +The current `configurator/page.tsx` renders the create form. We replace it with a list page view that wraps `ConfigurationList`. + +**Files:** + +- Modify: `src/app/license/[tokenId]/configurator/page.tsx` +- Create: `src/app/license/[tokenId]/configurator/components/ListView/ListView.tsx` +- Create: `src/app/license/[tokenId]/configurator/components/ListView/index.ts` +- Delete: `src/app/license/[tokenId]/configurator/components/View/View.tsx` +- Delete: `src/app/license/[tokenId]/configurator/components/View/index.ts` + +- [ ] **Step 1: Create the list page view component** + +Create `src/app/license/[tokenId]/configurator/components/ListView/ListView.tsx`: + +```typescript +'use client'; + +import { useQuery } from '@apollo/client'; +import { Loader } from '@/components/Loader'; +import { useEffect, useState } from 'react'; +import { PageSubtitle } from '@/components/PageSubtitle'; +import { ConfigurationList } from '@/app/license/[tokenId]/configurator/components/ConfigurationList'; +import { DEVELOPER_LICENSE_INFO } from '@/app/license/[tokenId]/configurator/components/View/View'; +import { useFragment } from '@/gql'; +import { USER_CONFIG_FRAGMENT } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm'; +import { Button } from '@/components/Button'; +import { useRouter } from 'next/navigation'; + +export const ListView = ({ params }: { params: Promise<{ tokenId: string }> }) => { + const [tokenId, setTokenId] = useState(); + const router = useRouter(); + + useEffect(() => { + const getTokenId = async () => { + const { tokenId: tokenIdParam } = await params; + setTokenId(Number(tokenIdParam)); + }; + void getTokenId(); + }, [params]); + + const { data, loading, error } = useQuery(DEVELOPER_LICENSE_INFO, { + variables: { tokenId: tokenId as number }, + skip: !tokenId, + }); + + const fragment = useFragment(USER_CONFIG_FRAGMENT, data?.developerLicense ?? null); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

There was an error fetching the license details

+
+ ); + } + + return ( +
+
+ + +
+

+ A vehicle sharing link is required for vehicle owners to grant data permissions to + your application.{' '} + + Learn how to use the configurationId with LIWD + +

+ {fragment?.clientId && tokenId && ( + + )} +
+ ); +}; + +export default ListView; +``` + +- [ ] **Step 2: Create the barrel export** + +Create `src/app/license/[tokenId]/configurator/components/ListView/index.ts`: + +```typescript +export { ListView } from './ListView'; +``` + +- [ ] **Step 3: Update `configurator/page.tsx`** + +Replace the contents of `src/app/license/[tokenId]/configurator/page.tsx`: + +```typescript +import { Metadata } from 'next'; +import { ListView } from './components/ListView'; +import configuration from '@/config'; + +export const metadata: Metadata = { + title: `Settings | ${configuration.appName}`, +}; + +const ConfiguratorListPage = ListView; +export default ConfiguratorListPage; +``` + +- [ ] **Step 4: Delete the old create View files** + +```bash +rm src/app/license/\[tokenId\]/configurator/components/View/View.tsx +rm src/app/license/\[tokenId\]/configurator/components/View/index.ts +``` + +- [ ] **Step 5: Run TypeScript check** + +```bash +npm run compile 2>&1 | head -40 +``` + +Expected: no errors. If `ListView.tsx` still references `DEVELOPER_LICENSE_INFO` from the deleted View, move the export. See note below. + +> **Note:** `DEVELOPER_LICENSE_INFO` is currently exported from `configurator/components/View/View.tsx` and imported by the `[id]` View. After deleting that file, move the `DEVELOPER_LICENSE_INFO` constant to a new shared file or into the new `ListView.tsx`. Update `[id]/components/View/View.tsx` to import it from `ListView` or a shared module. + +If the compiler reports a missing `DEVELOPER_LICENSE_INFO`: + +1. In `ListView.tsx`, the `DEVELOPER_LICENSE_INFO` import comes from the now-deleted path. Move the gql query definition directly into `ListView.tsx` and export it: + +```typescript +import { gql } from '@/gql'; + +export const DEVELOPER_LICENSE_INFO = gql(` + query DeveloperLicenseInfo($tokenId: Int!) { + developerLicense(by: {tokenId: $tokenId}) { + ...DeveloperLicenseSummaryFragment + ...SignerFragment + ...RedirectUriFragment + ...DeveloperLicenseVehiclesFragment + ...DeveloperJwtsFragment + ...UserConfigurationFragment + } + } +`); +``` + +2. Update `[id]/components/View/View.tsx` to import from the new location: + +```typescript +import { DEVELOPER_LICENSE_INFO } from '@/app/license/[tokenId]/configurator/components/ListView/ListView'; +``` + +- [ ] **Step 6: Run tests** + +```bash +npm test -- --passWithNoTests +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: replace /configurator root with configurations list page" +``` + +--- + +## Task 6: Create the `/new` (create) route + +**Files:** + +- Create: `src/app/license/[tokenId]/configurator/new/page.tsx` +- Create: `src/app/license/[tokenId]/configurator/new/components/View/View.tsx` +- Create: `src/app/license/[tokenId]/configurator/new/components/View/index.ts` + +- [ ] **Step 1: Create the create View** + +Create `src/app/license/[tokenId]/configurator/new/components/View/View.tsx`. + +This is the existing create View with one change: `router.replace` now goes to the list page instead of the edit page. + +```typescript +'use client'; + +import { useQuery } from '@apollo/client'; +import { Loader } from '@/components/Loader'; +import { useEffect, useState, useContext } from 'react'; +import { PageSubtitle } from '@/components/PageSubtitle'; +import { ConfigurationForm } from '@/app/license/[tokenId]/configurator/components/ConfigurationForm'; +import { FormProvider, useForm } from 'react-hook-form'; +import { + ComponentType, + DynamicFormProps, + PERMISSIONS, +} from '@/app/license/[tokenId]/configurator/components/ConfigurationForm/types'; +import { saveConfiguration } from '@/actions/configurations'; +import { useRouter } from 'next/navigation'; +import { DEVELOPER_LICENSE_INFO } from '@/app/license/[tokenId]/configurator/components/ListView/ListView'; +import { NotificationContext } from '@/context/notificationContext'; + +const parseArray = (val?: string) => + val + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean); + +const tryParseJSON = (val?: string) => { + try { + return val ? JSON.parse(val) : {}; + } catch { + return {}; + } +}; + +const formatComponent = (component: ComponentType) => { + switch (component) { + case 'LoginWithDimo': + return 'EMAIL_INPUT'; + case 'ShareVehiclesWithDimo': + return 'VEHICLE_MANAGER'; + case 'ExecuteAdvancedTransactionWithDimo': + return 'ADVANCED_TRANSACTION'; + } +}; + +function formatDate(date?: Date) { + if (!date) return ''; + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${mm}-${dd}-${yyyy}`; +} + +const buildJson = (values: DynamicFormProps): Record => { + const params: Record = {}; + const add = (key: string, val: unknown) => { + if (val === undefined || val === null || val === '') return; + if (Array.isArray(val)) { + if (val.length === 0) return; + params[key] = val.join(','); + } else if (typeof val === 'object') { + params[key] = JSON.stringify(val); + } else { + params[key] = String(val); + } + }; + + add('redirectUri', values.redirectUri); + add('entryState', formatComponent(values.component)); + add('utm', values.utm); + add( + 'expirationDate', + values.expirationDate ? formatDate(new Date(values.expirationDate)) : undefined, + ); + + if (values.component === 'LoginWithDimo') { + add('vehicles', parseArray(values.vehicles)); + add('vehicleMakes', parseArray(values.vehicleMakes)); + add('powerTrainTypes', parseArray(values.powerTrainTypes)); + } + + if (values.component === 'ShareVehiclesWithDimo') { + if (values.permissionsMode === 'template') { + add('permissionTemplateId', values.permissionTemplateId); + } else if (values.permissionsMode === 'custom') { + const permissionValues = PERMISSIONS.map((p) => { + const k = values.permissions?.find((vp) => vp === p.key); + if (k) return '1'; + return '0'; + }); + add('permissions', permissionValues?.join('')); + } + } + + if (values.component === 'ExecuteAdvancedTransactionWithDimo') { + add('value', values.value); + add('abi', tryParseJSON(values.abi as string)); + add('functionName', values.functionName); + add('args', parseArray(values.args as string)); + } + + return params; +}; + +export const View = ({ params }: { params: Promise<{ tokenId: string }> }) => { + const [tokenId, setTokenId] = useState(); + const { data, loading, error } = useQuery(DEVELOPER_LICENSE_INFO, { + variables: { tokenId: tokenId as number }, + skip: !tokenId, + }); + const router = useRouter(); + const { setNotification } = useContext(NotificationContext); + + useEffect(() => { + const getTokenId = async () => { + const { tokenId: tokenIdParam } = await params; + setTokenId(Number(tokenIdParam)); + }; + void getTokenId(); + }, [params]); + + const methods = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + component: 'ShareVehiclesWithDimo', + }, + }); + + const submit = async (data: DynamicFormProps) => { + try { + const body = { + client_id: data.client_id, + configuration_name: data.configuration_name, + configuration: buildJson(data), + }; + + await saveConfiguration(body); + + setNotification('Configuration successfully created', '', 'success'); + + // Redirect to the list, not to the edit page + router.replace(`/license/${tokenId}/configurator`); + } catch (error) { + console.log(error); + setNotification('Failed to create Configuration. Please try again.', '', 'error'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

There was an error fetching the license details

+
+ ); + } + + return ( +
+ +

+ A vehicle sharing link is required for vehicle owners to grant data permissions to + your application.{' '} + + Learn how to use the configurationId with LIWD + +

+ {data?.developerLicense && ( + + + + )} +
+ ); +}; + +export default View; +``` + +- [ ] **Step 2: Create the barrel export** + +Create `src/app/license/[tokenId]/configurator/new/components/View/index.ts`: + +```typescript +export { View } from './View'; +``` + +- [ ] **Step 3: Create the page** + +Create `src/app/license/[tokenId]/configurator/new/page.tsx`: + +```typescript +import { Metadata } from 'next'; +import { View } from './components/View'; +import configuration from '@/config'; + +export const metadata: Metadata = { + title: `Settings | ${configuration.appName}`, +}; + +const NewConfiguratorPage = View; +export default NewConfiguratorPage; +``` + +- [ ] **Step 4: Run TypeScript check** + +```bash +npm run compile 2>&1 | head -40 +``` + +Expected: no errors. + +- [ ] **Step 5: Run tests** + +```bash +npm test -- --passWithNoTests +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/license/\[tokenId\]/configurator/new/ +git commit -m "feat: add /configurator/new create route, redirect to list on save" +``` + +--- + +## Task 7: Simplify `Vehicles.tsx` entry point + +**Files:** + +- Modify: `src/app/license/[tokenId]/details/components/Vehicles/Vehicles.tsx` + +- [ ] **Step 1: Remove the configurationId state and fetch, simplify the button** + +In `src/app/license/[tokenId]/details/components/Vehicles/Vehicles.tsx`: + +Remove these imports: + +```typescript +import { useState } from 'react'; // remove if no other useState usage +import { getConfigurationByClientId } from '@/actions/configurations'; // remove +``` + +Remove these lines from the `Vehicles` component: + +```typescript +const [configurationId, setConfigurationId] = useState(''); + +useEffect(() => { + if (!fragment.clientId) return; + + const getConfigurationId = async (clientId: string) => { + try { + const { id } = await getConfigurationByClientId({ client_id: clientId }); + setConfigurationId(id); + } catch { + // configuration ID not found — "Configure Vehicle Sharing" button stays disabled + } + }; + + void getConfigurationId(fragment.clientId); +}, [fragment.clientId]); +``` + +Change the button's `onClick` from: + +```typescript +onClick={() => { + router.push( + configurationId + ? `/license/${fragment.tokenId}/configurator/${configurationId}` + : `/license/${fragment.tokenId}/configurator`, + ); +}} +``` + +To: + +```typescript +onClick={() => { + router.push(`/license/${fragment.tokenId}/configurator`); +}} +``` + +The final component body should look like: + +```typescript +export const Vehicles: FC = ({ license }) => { + const fragment = useFragment(DEVELOPER_LICENSE_VEHICLES_FRAGMENT, license); + const { data, loading, error } = useQuery(GET_VEHICLE_COUNT_BY_CLIENT_ID, { + variables: { clientId: fragment.clientId }, + }); + const router = useRouter(); + + return ( +
+
+ +
+ + +
+
+
+ {!!error &&

We had trouble fetching the connected vehicles

} + {loading && } + {!!data && ( + + )} +
+
+
+ ); +}; +``` + +- [ ] **Step 2: Run TypeScript check** + +```bash +npm run compile 2>&1 | head -40 +``` + +Expected: no errors. + +- [ ] **Step 3: Run tests** + +```bash +npm test -- --passWithNoTests +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/license/\[tokenId\]/details/components/Vehicles/Vehicles.tsx +git commit -m "feat: simplify Vehicles entry point — always route to configurator list" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec requirement | Covered by | +| -------------------------------------------- | -------------------------------------------------- | +| Backend: list endpoint returns array | Task 0 (backend prereq) + Task 1 (frontend action) | +| Backend: DELETE endpoint | Task 0 (backend prereq) + Task 1 (frontend action) | +| `getConfigurationsByClientId` returns array | Task 1 | +| `deleteConfiguration` action | Task 1 | +| `/configurator` becomes list page | Task 5 | +| `/configurator/new` creates configuration | Task 6 | +| `/configurator/[id]` edit unchanged in logic | Task 3 (import consolidation only) | +| List page: New Configuration button | Task 5 | +| List page: table with Name + Component type | Task 4 | +| List page: Edit per row | Task 4 | +| List page: Delete with confirmation | Task 4 | +| List page: empty state | Task 4 | +| Shared form components consolidated | Task 2 + Task 3 | +| Create redirects to list on save | Task 6 | +| Vehicles.tsx simplified | Task 7 | + +All requirements covered. No gaps found. diff --git a/docs/superpowers/plans/2026-05-27-frontend-redesign.md b/docs/superpowers/plans/2026-05-27-frontend-redesign.md new file mode 100644 index 00000000..283126b2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-frontend-redesign.md @@ -0,0 +1,1869 @@ +# Frontend Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Modernize the DIMO Developer Console with DIMO Teal design tokens, dark/light mode, a collapsible grouped sidebar, shadcn/ui primitives, and UX simplifications (no environment labels, balances in $). + +**Architecture:** Foundation-first — CSS variables + Tailwind darkMode → ThemeProvider + layout → shadcn primitives → cleanup. Each task is independently shippable. No flows broken: existing component props and hook APIs are preserved; only visuals and the notification system change. + +**Tech Stack:** Next.js App Router, Tailwind CSS, shadcn/ui, next-themes, Sonner, @heroicons/react (kept) + +--- + +## Task 1: shadcn/ui Init + DIMO Token Layer + +**Files:** + +- Modify: `src/app/globals.css` +- Modify: `tailwind.config.ts` +- Create: `src/lib/utils.ts` +- Creates (auto): `components.json` + +- [ ] **Step 1: Install dependencies** + +```bash +npm install next-themes +npm install lucide-react +npx shadcn@latest init +``` + +When prompted by shadcn init: + +- Style: **Default** +- Base color: **Neutral** +- Global CSS: `src/app/globals.css` → **yes** +- CSS variables: **yes** +- Tailwind config: `tailwind.config.ts` +- Components alias: `@/components/ui` +- React Server Components: **yes** + +- [ ] **Step 2: Verify `src/lib/utils.ts` was created by shadcn init** + +It should contain: + +```ts +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +If not created, create it now with the above content. Also verify `clsx` and `tailwind-merge` were added to `package.json`. + +- [ ] **Step 3: Replace globals.css with DIMO Teal tokens** + +Replace the entire contents of `src/app/globals.css`: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 180 33% 96%; + --foreground: 180 71% 13%; + + --card: 0 0% 100%; + --card-foreground: 180 71% 13%; + + --sidebar: 0 0% 100%; + + --popover: 0 0% 100%; + --popover-foreground: 180 71% 13%; + + --primary: 178 66% 40%; + --primary-foreground: 0 0% 100%; + + --secondary: 180 33% 93%; + --secondary-foreground: 180 71% 13%; + + --muted: 180 33% 93%; + --muted-foreground: 178 78% 23%; + + --accent: 180 33% 93%; + --accent-foreground: 180 71% 13%; + + --destructive: 0 78% 42%; + --destructive-foreground: 0 0% 100%; + + --border: 177 51% 80%; + --input: 177 51% 80%; + --ring: 178 66% 40%; + + --radius: 0.625rem; + } + + .dark { + --background: 180 32% 3%; + --foreground: 177 65% 88%; + + --card: 180 30% 9%; + --card-foreground: 177 65% 88%; + + --sidebar: 180 35% 6%; + + --popover: 180 30% 9%; + --popover-foreground: 177 65% 88%; + + --primary: 178 66% 40%; + --primary-foreground: 0 0% 0%; + + --secondary: 180 30% 12%; + --secondary-foreground: 177 60% 64%; + + --muted: 180 22% 30%; + --muted-foreground: 177 60% 64%; + + --accent: 180 30% 12%; + --accent-foreground: 177 65% 88%; + + --destructive: 0 99% 71%; + --destructive-foreground: 0 0% 0%; + + --border: 180 28% 14%; + --input: 180 28% 14%; + --ring: 178 66% 40%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground text-sm font-light not-italic; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} +``` + +- [ ] **Step 4: Update `tailwind.config.ts` — darkMode + token colors** + +Replace the entire file: + +```ts +import type { Config } from 'tailwindcss'; + +const config: Config = { + darkMode: ['class'], + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + 'background': 'hsl(var(--background))', + 'foreground': 'hsl(var(--foreground))', + 'card': { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + 'sidebar': 'hsl(var(--sidebar))', + 'popover': { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + 'primary': { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + 'secondary': { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + 'muted': { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + 'accent': { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + 'destructive': { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + 'border': 'hsl(var(--border))', + 'input': 'hsl(var(--input))', + 'ring': 'hsl(var(--ring))', + /* Legacy aliases — keep so existing classnames don't break during migration */ + 'surface': { + default: 'hsl(var(--card))', + sunken: 'hsl(var(--background))', + raised: 'hsl(var(--accent))', + }, + 'cta': { + default: 'hsl(var(--accent))', + disabled: 'hsl(var(--muted))', + }, + 'feedback': { + success: '#0D7038', + error: 'hsl(var(--destructive))', + }, + 'text': { + secondary: 'hsl(var(--muted-foreground))', + }, + /* Existing palette scales — keep for gradual cleanup */ + 'grey': { + '50': '#f5f6f6', + '100': '#e4e8e9', + '200': '#ccd4d5', + '300': '#a9b5b7', + '400': '#7e8f92', + '500': '#637377', + '600': '#556166', + '700': '#495256', + '800': '#40484a', + '900': '#393e40', + '950': '#232729', + }, + 'primary-scale': { + '50': '#f1fcfa', + '100': '#d0f7f2', + '200': '#b7f2eb', + '300': '#6aded5', + '400': '#3bc6be', + '500': '#22aaa5', + '600': '#198886', + '700': '#186d6d', + '800': '#185657', + '900': '#184849', + '950': '#08292b', + }, + 'red': { + '50': '#fff1f1', + '100': '#ffe1e1', + '200': '#ffc8c8', + '300': '#ffa1a1', + '400': '#fe6b6b', + '500': '#f85454', + '600': '#e51d1d', + '700': '#c01515', + '800': '#9f1515', + '900': '#841818', + '950': '#480707', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [], +}; +export default config; +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +```bash +npm run compile +``` + +Expected: no errors. If shadcn added conflicting config, reconcile with the above. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/globals.css tailwind.config.ts src/lib/utils.ts components.json package.json package-lock.json +git commit -m "feat: add DIMO Teal token layer and shadcn/ui foundation" +``` + +--- + +## Task 2: ThemeProvider + ThemeToggle + +**Files:** + +- Modify: `src/layouts/RootLayout/RootLayout.tsx` +- Create: `src/components/Header/ThemeToggle.tsx` + +- [ ] **Step 1: Wrap RootLayout with ThemeProvider** + +Replace `src/layouts/RootLayout/RootLayout.tsx`: + +```tsx +'use client'; + +import React, { useEffect } from 'react'; +import { ThemeProvider } from 'next-themes'; +import { dimoFont } from '@/utils/font'; +import configuration from '@/config'; +import '@/app/globals.css'; +import QueryProvider from '@/hoc/QueryProvider'; +import { useMixPanel } from '@/hooks'; + +export const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { + const { initMixPanel } = useMixPanel(); + useEffect(() => { + initMixPanel(); + }, []); + + return ( + + + + {children} + + + + ); +}; + +export default RootLayout; +``` + +Note: `suppressHydrationWarning` on `` is required by next-themes to suppress the class mismatch warning during SSR hydration. + +- [ ] **Step 2: Create ThemeToggle component** + +Create `src/components/Header/ThemeToggle.tsx`: + +```tsx +'use client'; + +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; +import { cn } from '@/lib/utils'; + +export const ThemeToggle = () => { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( +
+ ); + } + + const isDark = theme === 'dark'; + + return ( +
setTheme(isDark ? 'light' : 'dark')} + > + + ☀ + + + ☾ + +
+ ); +}; +``` + +- [ ] **Step 3: Add ThemeToggle to Header** + +In `src/components/Header/Header.tsx`, import and add `ThemeToggle` to the `user-information` div: + +```tsx +import { type FC } from 'react'; +import './Header.css'; +import { usePathname } from 'next/navigation'; +import { getPageTitle } from '@/config/navigation'; +import { CreditsWidget } from '@/components/CreditsWidget'; +import { AccountInfoButton } from '@/components/AccountInfoButton'; +import { DeveloperSupportButton } from '@/components/DeveloperSupportButton'; +import { ThemeToggle } from '@/components/Header/ThemeToggle'; + +export const Header: FC = () => { + const pathname = usePathname(); + + return ( +
+

{getPageTitle(pathname) ?? ''}

+
+ + + + +
+
+ ); +}; +``` + +- [ ] **Step 4: Run the dev server and verify the toggle works** + +```bash +npm run dev +``` + +Open `http://localhost:3000`. Verify: + +- The sun/moon toggle pill appears in the header +- Clicking it switches between light and dark mode +- The mode persists across page refresh +- No hydration errors in the console + +- [ ] **Step 5: Run tests** + +```bash +npm test +``` + +Expected: snapshot tests for Header will fail (the component changed). Update them: + +```bash +npm run test:update-snap +``` + +Then re-run `npm test` — all should pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/layouts/RootLayout/RootLayout.tsx src/components/Header/ThemeToggle.tsx src/components/Header/Header.tsx +git commit -m "feat: add dark/light mode toggle via next-themes" +``` + +--- + +## Task 3: LayoutContext + Sidebar Collapse State + +**Files:** + +- Modify: `src/context/LayoutContext.ts` +- Modify: `src/hoc/withLayout.tsx` + +- [ ] **Step 1: Write the failing test** + +In `__tests__/hoc/withLayout.test.tsx` (create if it doesn't exist): + +```tsx +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useContext } from 'react'; +import { LayoutContext } from '@/context/LayoutContext'; +import { withLayout } from '@/hoc/withLayout'; + +const TestConsumer = () => { + const { isSidebarCollapsed, setSidebarCollapsed } = useContext(LayoutContext); + return ( +
+ {isSidebarCollapsed ? 'collapsed' : 'expanded'} + +
+ ); +}; + +const Wrapped = withLayout(TestConsumer); + +describe('withLayout', () => { + it('provides isSidebarCollapsed defaulting to false', () => { + render(); + expect(screen.getByTestId('state').textContent).toBe('expanded'); + }); + + it('setSidebarCollapsed updates state', async () => { + render(); + await userEvent.click(screen.getByText('collapse')); + expect(screen.getByTestId('state').textContent).toBe('collapsed'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npm test -- --testPathPattern="withLayout" --no-coverage +``` + +Expected: FAIL — `isSidebarCollapsed` not found on context. + +- [ ] **Step 3: Update LayoutContext** + +Replace `src/context/LayoutContext.ts`: + +```ts +import { createContext } from 'react'; + +interface ILayoutContext { + isFullScreenMenuOpen: boolean; + setIsFullScreenMenuOpen: (open: boolean) => void; + isSidebarCollapsed: boolean; + setSidebarCollapsed: (collapsed: boolean) => void; +} + +export const LayoutContext = createContext({ + isFullScreenMenuOpen: false, + setIsFullScreenMenuOpen: () => {}, + isSidebarCollapsed: false, + setSidebarCollapsed: () => {}, +}); +``` + +- [ ] **Step 4: Update withLayout HOC to manage collapse state** + +Replace `src/hoc/withLayout.tsx`: + +```tsx +import React, { ComponentType, useEffect, useState } from 'react'; +import { LayoutContext } from '@/context/LayoutContext'; + +const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed'; + +export const withLayout =

(WrappedComponent: ComponentType

) => { + const HOC: React.FC

= (props) => { + const [isFullScreenMenuOpen, setIsFullScreenMenuOpen] = useState(false); + const [isSidebarCollapsed, setSidebarCollapsedState] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); + if (stored === 'true') setSidebarCollapsedState(true); + }, []); + + const setSidebarCollapsed = (collapsed: boolean) => { + setSidebarCollapsedState(collapsed); + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + }; + + return ( + + + + ); + }; + + HOC.displayName = `withLayout(${WrappedComponent.displayName || WrappedComponent.name})`; + return HOC; +}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +npm test -- --testPathPattern="withLayout" --no-coverage +``` + +Expected: PASS + +- [ ] **Step 6: Run full test suite** + +```bash +npm test +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/context/LayoutContext.ts src/hoc/withLayout.tsx +git commit -m "feat: add sidebar collapse state to LayoutContext" +``` + +--- + +## Task 4: Navigation Sections + Sidebar Redesign + +**Files:** + +- Modify: `src/config/navigation.ts` +- Modify: `src/components/Menu/Menu.tsx` +- Modify: `src/components/Menu/Menu.css` +- Modify: `src/components/Menu/MenuItem/MenuItem.tsx` +- Modify: `src/components/Menu/MenuItem/MenuItem.css` +- Modify: `src/layouts/AuthorizedLayout/AuthorizedLayout.tsx` +- Modify: `src/layouts/AuthorizedLayout/AuthorizedLayout.css` + +- [ ] **Step 1: Add section structure to navigation config** + +In `src/config/navigation.ts`, add a `NavSection` type and `getNavSections` export after the existing exports: + +```ts +export type NavItem = { + label: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon: React.FC; + iconClassName: string; + link: string | (() => void); + external: boolean; + disabled: boolean; + hidden?: boolean; +}; + +export type NavSection = { + label: string; + items: NavItem[]; +}; + +export const getNavSections = (includeConnections: boolean = true): NavSection[] => [ + { + label: 'Workspace', + items: [ + { + label: 'Home', + icon: HomeIcon, + iconClassName: 'h-4 w-4', + link: '/app', + external: false, + disabled: false, + }, + { + label: 'Webhooks', + icon: IntegrationIcon, + iconClassName: 'h-4 w-4 fill-current stroke-current stroke-1', + link: '/webhooks', + external: false, + disabled: false, + }, + ...(includeConnections + ? [ + { + label: 'Connections', + icon: ConnectionsIcon, + iconClassName: 'h-4 w-4', + link: '/connections', + external: false, + disabled: false, + }, + ] + : []), + ], + }, + { + label: 'Resources', + items: [ + { + label: 'Data Explorer', + icon: ChipIcon, + iconClassName: 'h-4 w-4', + link: '/explorer', + external: false, + disabled: false, + }, + { + label: 'Documentation', + icon: SummarizeIcon, + iconClassName: 'h-4 w-4', + link: 'https://dimo.org/docs', + external: true, + disabled: false, + }, + { + label: 'API Status', + icon: MonitorHeartIcon, + iconClassName: 'h-4 w-4', + link: 'https://stats.uptimerobot.com/snU0rkEEah', + external: true, + disabled: false, + }, + ], + }, +]; +``` + +Add `import React from 'react';` at the top of the file if not already present. + +- [ ] **Step 2: Rewrite MenuItem to use token classes** + +Replace `src/components/Menu/MenuItem/MenuItem.tsx`: + +```tsx +import { type FC, PropsWithChildren, useContext } from 'react'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { LayoutContext } from '@/context/LayoutContext'; +import { cn } from '@/lib/utils'; + +interface IProps { + link: string | (() => void); + disabled: boolean; + external: boolean; + iconClassName: string; + label: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon: FC; + isHighlighted?: boolean; + isCollapsed?: boolean; +} + +export const MenuItem: FC = ({ + link, + external, + disabled, + icon: Icon, + iconClassName, + label, + isHighlighted, + isCollapsed, +}) => { + const { isFullScreenMenuOpen, setIsFullScreenMenuOpen } = useContext(LayoutContext); + + const closeFullScreenMenu = () => { + if (isFullScreenMenuOpen) setIsFullScreenMenuOpen(false); + }; + + const handleFunctionClick = () => { + if (typeof link === 'function') { + link(); + closeFullScreenMenu(); + } + }; + + const Wrapper: FC = ({ children }) => { + if (typeof link === 'function') { + return ; + } + return ( + + {children} + + ); + }; + + return ( +

  • + + {!isCollapsed && {label}} + {isCollapsed && ( + + {label} + + )} +
  • + ); +}; +``` + +Replace `src/components/Menu/MenuItem/MenuItem.css` with an empty file (styles are now inline Tailwind): + +```css +/* styles moved to Tailwind classes in MenuItem.tsx */ +``` + +- [ ] **Step 3: Rewrite Menu.tsx with grouped sections + collapse button** + +Replace `src/components/Menu/Menu.tsx`: + +```tsx +import { type FC, useContext } from 'react'; +import Image from 'next/image'; +import { usePathname, useRouter } from 'next/navigation'; +import { XMarkIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/solid'; +import * as Sentry from '@sentry/nextjs'; + +import { MenuItem } from '@/components/Menu/MenuItem'; +import { getNavSections, bottomMenu } from '@/config/navigation'; +import { useHasDeveloperLicenses } from '@/hooks'; +import { LayoutContext } from '@/context/LayoutContext'; +import { LoadingStatusContext } from '@/context/LoadingStatusContext'; +import { withLoadingStatus } from '@/hoc'; +import { signOut } from '@/actions/user'; +import { turnkeyClient } from '@/config/turnkey'; +import { GlobalAccountSession, removeFromSession } from '@/utils/sessionStorage'; +import { EmbeddedKey, removeFromLocalStorage } from '@/utils/localStorage'; +import { queryClient } from '@/hoc/QueryProvider'; +import { LogoutIcon } from '@/components/Icons/LogoutIcon'; +import { cn } from '@/lib/utils'; + +import './Menu.css'; + +export const Menu: FC = withLoadingStatus(() => { + const { setLoadingStatus, clearLoadingStatus } = useContext(LoadingStatusContext); + const { + isFullScreenMenuOpen, + setIsFullScreenMenuOpen, + isSidebarCollapsed, + setSidebarCollapsed, + } = useContext(LayoutContext); + const pathname = usePathname(); + const router = useRouter(); + const { hasDeveloperLicenses, loading: licensesLoading } = useHasDeveloperLicenses(); + + const onSignOut = async () => { + try { + setLoadingStatus({ status: 'loading', label: 'Signing out' }); + await signOut(); + await turnkeyClient.logout(); + queryClient.clear(); + removeFromSession(GlobalAccountSession); + removeFromLocalStorage(EmbeddedKey); + clearLoadingStatus(); + router.replace('/sign-in'); + } catch (err) { + Sentry.captureException(err); + setLoadingStatus({ status: 'error', label: 'There was a problem signing you out' }); + } + }; + + const isHighlighted = (link: string | (() => void)) => + typeof link === 'string' && pathname.startsWith(link); + + const logoutItem = { + label: 'Logout', + icon: LogoutIcon, + iconClassName: 'h-4 w-4', + link: onSignOut, + external: false, + disabled: false, + }; + + const sections = getNavSections(licensesLoading || hasDeveloperLicenses); + + return ( +
    + {/* Logo */} +
    + {!isSidebarCollapsed && ( + DIMO Logo + )} + {isSidebarCollapsed && ( +
    + D +
    + )} + {isFullScreenMenuOpen && ( + + )} +
    + + {/* Grouped nav sections */} + + + {/* Bottom section */} +
    +
      + {bottomMenu.map((item) => ( + + ))} + +
    +
    + + {/* Collapse toggle button — desktop only */} + +
    + ); +}); +``` + +- [ ] **Step 4: Replace Menu.css** + +Replace `src/components/Menu/Menu.css`: + +```css +.main-menu { + @apply relative w-full px-3 py-5 flex flex-col gap-2 bg-sidebar; + transition: width 200ms ease; +} + +.logo-row { + @apply flex flex-row items-start; +} + +.bottom-section { + @apply border-t border-border pt-3 mt-2; +} + +.collapse-btn { + @apply absolute -right-2.5 top-1/2 -translate-y-1/2 z-10 + w-5 h-5 rounded-full bg-card border border-border + items-center justify-center shadow-sm + hover:bg-accent transition-colors; +} +``` + +- [ ] **Step 5: Update AuthorizedLayout.css for sidebar width transition** + +Replace `src/layouts/AuthorizedLayout/AuthorizedLayout.css`: + +```css +.main { + @apply flex flex-row bg-background min-h-screen items-stretch; +} + +.sidebar-container { + @apply hidden md:block md:flex-shrink-0; + transition: width 200ms ease; +} + +.sidebar-container.expanded { + @apply md:w-[168px]; +} + +.sidebar-container.collapsed { + @apply md:w-[50px]; +} + +.header-container { + @apply flex flex-row items-center; +} + +.menu-header-button { + @apply md:hidden; +} + +.app-content { + @apply flex flex-col flex-1 min-w-0; +} + +.page-content { + @apply overflow-y-auto overflow-x-auto p-4 flex-1; +} +``` + +- [ ] **Step 6: Update AuthorizedLayout.tsx to apply width classes** + +In `src/layouts/AuthorizedLayout/AuthorizedLayout.tsx`, read `isSidebarCollapsed` from `LayoutContext` and apply the correct class: + +```tsx +'use client'; +import React, { useContext } from 'react'; +import { MenuButton } from '@/components/Menu/MenuButton'; +import { + withCredits, + withNotifications, + withGlobalAccounts, + withApollo, + withAccountInformation, + withLayout, +} from '@/hoc'; +import { Header } from '@/components/Header'; +import { Menu } from '@/components/Menu'; +import { FullScreenMenu } from '@/components/Menu/FullScreenMenu'; +import { LayoutContext } from '@/context/LayoutContext'; +import { cn } from '@/lib/utils'; +import './AuthorizedLayout.css'; + +const Providers = withNotifications( + withGlobalAccounts( + withLayout( + withCredits( + withApollo( + withAccountInformation(({ children }: { children: React.ReactNode }) => ( + <>{children} + )), + ), + ), + ), + ), +); + +export const AuthorizedLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return ( + + {children} + + ); +}; + +const Layout = ({ children }: { children: React.ReactNode }) => { + const { isSidebarCollapsed } = useContext(LayoutContext); + + return ( +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    {children}
    +
    +
    + ); +}; + +export default AuthorizedLayout; +``` + +- [ ] **Step 7: Run dev server and verify sidebar** + +```bash +npm run dev +``` + +Verify: + +- Sidebar shows "Workspace" and "Resources" section labels +- Active nav item has teal highlight +- Clicking the chevron button collapses the sidebar to icon-only (~50px) +- Collapsed state persists on page refresh +- Mobile full-screen menu still works (hamburger button) + +- [ ] **Step 8: Run tests and update snapshots** + +```bash +npm test +``` + +Update any failing snapshots: + +```bash +npm run test:update-snap +``` + +Then verify: + +```bash +npm test +``` + +Expected: all pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/config/navigation.ts src/components/Menu/ src/layouts/AuthorizedLayout/ src/context/LayoutContext.ts src/hoc/withLayout.tsx +git commit -m "feat: redesign sidebar with grouped sections and collapse toggle" +``` + +--- + +## Task 5: Header CSS + CreditsWidget Pill Redesign + +**Files:** + +- Modify: `src/components/Header/Header.css` +- Modify: `src/components/CreditsWidget/CreditsWidget.tsx` +- Modify: `src/components/CreditsWidget/CreditsWidget.css` + +- [ ] **Step 1: Update Header.css to use tokens** + +Replace `src/components/Header/Header.css`: + +```css +.header { + @apply flex h-12 md:h-14 items-center justify-between px-4 md:px-6 + bg-sidebar border-b border-border w-full; + + .page-title { + @apply text-lg font-bold text-foreground; + } + + .user-information { + @apply flex flex-row gap-3 items-center; + } +} +``` + +- [ ] **Step 2: Update CreditsWidget visual to teal pill** + +Replace `src/components/CreditsWidget/CreditsWidget.tsx` — keep all existing logic, only change the JSX structure for the `small` variant: + +```tsx +import { FC, useContext, useEffect, useState } from 'react'; +import { useGlobalAccount } from '@/hooks'; +import { CreditsContext } from '@/context/creditsContext'; +import { isCollaborator, isOwner } from '@/utils/user'; +import { formatToCurrency } from '@/utils/formatBalance'; +import * as Sentry from '@sentry/nextjs'; +import { PlusIcon, WalletIcon } from '@/components/Icons'; +import { Button } from '@/components/Button'; +import { AccountInfoButton } from '@/components/AccountInfoButton'; +import { cn } from '@/lib/utils'; +import './CreditsWidget.css'; + +interface ICreditsWidgetProps { + variant?: 'small' | 'large'; +} + +const DCX_IN_USD = 0.001; + +export const CreditsWidget: FC = ({ variant = 'small' }) => { + const [dcxBalance, setDcxBalance] = useState('$0.00'); + const { currentUser, getCurrentDcxBalance } = useGlobalAccount(); + const { setIsOpen } = useContext(CreditsContext); + + const loadAndFormatDcxBalance = async () => { + try { + if (isCollaborator(currentUser?.role ?? '')) return; + const balance = await getCurrentDcxBalance(); + setDcxBalance(formatToCurrency(balance * DCX_IN_USD)); + } catch (error: unknown) { + Sentry.captureException(error); + console.error(error); + } + }; + + useEffect(() => { + if (!currentUser) return; + void loadAndFormatDcxBalance(); + }, [currentUser]); + + const handleBuyCredits = () => { + if (isOwner(currentUser?.role ?? '')) { + setIsOpen(true); + } + }; + + if (variant === 'large') { + return ( +
    +
    +
    + +
    +
    +

    {dcxBalance}

    +
    +

    Current Balance

    +
    +
    +
    + + +
    +
    +
    + ); + } + + return ( +
    + $ + {dcxBalance.replace('$', '')} + {isOwner(currentUser?.role ?? '') && ( + + )} +
    + ); +}; +``` + +Replace `src/components/CreditsWidget/CreditsWidget.css`: + +```css +.credits-large { + @apply p-4 rounded-xl flex flex-col bg-card border border-border; +} +``` + +- [ ] **Step 3: Run dev server and verify the header** + +```bash +npm run dev +``` + +Verify: + +- Header shows page title on left +- Right side: teal credits pill (shows `$12.40` style) → theme toggle → support button → account button +- No "DCX" text visible anywhere in the credits display + +- [ ] **Step 4: Run tests and update snapshots** + +```bash +npm test +npm run test:update-snap +npm test +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/Header/Header.css src/components/CreditsWidget/ +git commit -m "feat: redesign header credits as teal pill, show balance in USD" +``` + +--- + +## Task 6: shadcn Button Migration + +**Files:** + +- Run: `npx shadcn@latest add button` +- Modify: `src/components/Button/Button.tsx` +- Modify: `src/components/Button/Button.css` + +- [ ] **Step 1: Add shadcn Button component** + +```bash +npx shadcn@latest add button +``` + +This creates `src/components/ui/button.tsx`. + +- [ ] **Step 2: Rewrite Button to wrap shadcn with existing variant API** + +The goal is zero changes to call sites — existing `className` prop usage like `