mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 01:53:19 +00:00
* feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType<T>` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) * feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. * refactor(web): proposal pages on domain layer, proposal form on react-hook-form * refactor(web): admin workspace on domain layer * refactor(web): customer management on domain layer + react-hook-form * refactor(web): pricing library on domain layer + react-hook-form * refactor(web): dashboards on domain layer * test(web): domain use-case hook coverage * refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. * fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
99 lines
4 KiB
TypeScript
99 lines
4 KiB
TypeScript
// Customers domain use-case hooks: create/update must invalidate the list
|
|
// whole domain key family (customersKeys.all — lists AND cached search
|
|
// autocompletes; code-review finding, Phase 4) and toast. The domain api module is
|
|
// mocked — no axios traffic.
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
|
import { toast } from 'react-toastify';
|
|
import { createQueryHarness } from './hookTestUtils';
|
|
import {
|
|
customersKeys,
|
|
useCreateCustomer,
|
|
useUpdateCustomer,
|
|
} from '../customers/use-cases';
|
|
import { customersApi } from '../customers/api';
|
|
import type { Customer } from '../customers/types';
|
|
|
|
vi.mock('../customers/api', () => ({
|
|
customersApi: {
|
|
search: vi.fn(),
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('react-toastify', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
const customer = { id: 'c-1', name: 'Acme Corp' } as unknown as Customer;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('customersKeys', () => {
|
|
it('list keys nest under lists() so one invalidation hits every page/filter', () => {
|
|
expect(customersKeys.all).toEqual(['customers']);
|
|
expect(customersKeys.lists()).toEqual(['customers', 'list']);
|
|
expect(customersKeys.list({ page: 3 })).toEqual(['customers', 'list', { page: 3 }]);
|
|
expect(customersKeys.search('acme')).toEqual(['customers', 'search', 'acme']);
|
|
});
|
|
});
|
|
|
|
describe('useCreateCustomer', () => {
|
|
it('invalidates the whole customers domain (lists + search caches) and toasts on success', async () => {
|
|
vi.mocked(customersApi.create).mockResolvedValue(customer);
|
|
const { queryClient, wrapper } = createQueryHarness();
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const { result } = renderHook(() => useCreateCustomer(), { wrapper });
|
|
result.current.mutate({ name: 'Acme Corp' } as never);
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
|
|
expect(toast.success).toHaveBeenCalledWith('Customer created');
|
|
});
|
|
|
|
it('toasts the failure and skips invalidation', async () => {
|
|
vi.mocked(customersApi.create).mockRejectedValue(new Error('duplicate name'));
|
|
const { queryClient, wrapper } = createQueryHarness();
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const { result } = renderHook(() => useCreateCustomer(), { wrapper });
|
|
result.current.mutate({ name: 'Acme Corp' } as never);
|
|
|
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
expect(toast.error).toHaveBeenCalledWith('Failed to create customer: duplicate name');
|
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useUpdateCustomer', () => {
|
|
it('passes id + payload through and invalidates the whole customers domain on success', async () => {
|
|
vi.mocked(customersApi.update).mockResolvedValue(customer);
|
|
const { queryClient, wrapper } = createQueryHarness();
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const { result } = renderHook(() => useUpdateCustomer(), { wrapper });
|
|
const data = { name: 'Acme Corp (Renamed)' } as never;
|
|
result.current.mutate({ id: 'c-1', data });
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
expect(customersApi.update).toHaveBeenCalledWith('c-1', data);
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
|
|
expect(toast.success).toHaveBeenCalledWith('Customer updated');
|
|
});
|
|
|
|
it('toasts the failure on update error', async () => {
|
|
vi.mocked(customersApi.update).mockRejectedValue(new Error('not found'));
|
|
const { wrapper } = createQueryHarness();
|
|
|
|
const { result } = renderHook(() => useUpdateCustomer(), { wrapper });
|
|
result.current.mutate({ id: 'c-404', data: { name: 'x' } as never });
|
|
|
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
expect(toast.error).toHaveBeenCalledWith('Failed to update customer: not found');
|
|
});
|
|
});
|