From d8ebf98dfd20cbbdddc37383eacf637a19c5ed9f Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 7 Jul 2026 22:03:30 +0200 Subject: [PATCH 1/4] feat(erli,web): checkbox-reveal Allegro credentials panel + offer wizard category/parameters steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the operator-facing half of the Erli-owned Allegro category-catalog feature (#1384, part of epic #1381, depends on #1382/#1383): - ErliCredentialsPanel gains a "Browse Allegro categories when creating Erli offers" checkbox that reveals Client ID / Client Secret fields (masked, show/hide toggle). Saving sequences two existing mutations from one click: useUpdateConnectionCredentialsMutation (credentials pair, merged with apiKey) then useUpdateConnectionMutation (config patch for allegroCategoryAccessEnabled) — credentials first so a failure never leaves the flag advertising access the backend can't serve. - ErliCreateOfferWizard renders the reused Allegro CategoryPicker + CategoryParametersStep (fed by the existing useCategoryParametersQuery) as dedicated Category / Category-parameters steps when connection.config.allegroCategoryAccessEnabled is true — per ADR-031's correction, this per-connection-instance config flag is the FE gating signal, not the static, per-adapterKey connection.supportedCapabilities. Falls back to today's plain-text field + a link to the connection's edit page otherwise. Stepper labels and needsProductParameters become capability-conditional; erliCreateOfferSchema gains a parameters slice serialized into overrides.parameters on submit (no BE change needed). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RpsVFTpKZ1HoowEUKzbnWu Signed-off-by: norbert-kulus-blockydevs --- ...reate-erli-offer-request-to-form-values.ts | 7 + .../erli/erli-create-offer-wizard.test.tsx | 105 +++++++ .../erli/erli-create-offer-wizard.tsx | 259 ++++++++++++++++-- .../erli/erli-create-offer.schema.ts | 22 +- .../erli-credentials-panel.test.tsx | 138 +++++++++- .../components/erli-credentials-panel.tsx | 191 +++++++++++-- 6 files changed, 658 insertions(+), 64 deletions(-) diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts index 7d06ef493..f73c95bfd 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts @@ -62,6 +62,13 @@ export function createErliOfferRequestToFormValues( publishImmediately: request.publishImmediately, dispatchPeriod: dispatch.period, dispatchUnit: dispatch.unit, + // #1384 — category-parameter values are not reconstructed from the wire + // snapshot on retry (parity gap, same simplification the schema's + // `.default({})` already assumes for a fresh wizard); the operator + // re-fills the Category-parameters step if the category access flag is + // active. `categoryId` above is restored, so the step re-fetches the + // right schema. + parameters: {}, }; } diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx index 059e7ff68..462e98a2d 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx @@ -28,6 +28,15 @@ const erliConnection: Connection = { updatedAt: '2026-01-01T00:00:00Z', }; +// #1384 — a per-connection-instance signal (`config.allegroCategoryAccessEnabled`), +// deliberately NOT reflected in `supportedCapabilities` (static per-adapterKey, +// see ADR-031 "Correction") — both connections keep the same `supportedCapabilities`. +const erliConnectionWithCategoryAccess: Connection = { + ...erliConnection, + id: 'conn_erli_2', + config: { ...erliConnection.config, allegroCategoryAccessEnabled: true }, +}; + function productWith(images: string[] | null): Product { return { id: 'ol_product_abc', @@ -205,4 +214,100 @@ describe('ErliCreateOfferWizard', () => { dispatchTime: { period: 5, unit: 'hour' }, }); }); + + // #1384 — capability-conditional category/parameters steps. + describe('Allegro category access (#1384)', () => { + it('keeps the plain-text category field and shows the fallback hint when access is not configured', async () => { + renderWithProviders( + , + { apiClient: mocks(productWith(['https://cdn.example.com/a.jpg'])) }, + ); + + await pickVariantAndAdvance(); + + expect(await screen.findByPlaceholderText(/e\.g\. 12345/i)).toBeInTheDocument(); + expect( + screen.getByText(/add allegro category browsing to this connection/i), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /configure category browsing/i })).toHaveAttribute( + 'href', + `/connections/${erliConnection.id}/edit`, + ); + // Only the 3-step shape — no "Category" / "Category parameters" steps. + expect(screen.queryByText('Category parameters')).not.toBeInTheDocument(); + }); + + it('renders the Category and Category-parameters steps, blocks on a required parameter, and submits overrides.parameters', async () => { + const mockApi = mocks(productWith(['https://cdn.example.com/a.jpg']), { + connections: { list: vi.fn().mockResolvedValue([erliConnectionWithCategoryAccess]) }, + listings: { + createOffer: vi + .fn() + .mockResolvedValue({ jobId: 'job-1', offerCreationRecordId: 'rec-1' }), + resolveCategory: vi.fn().mockResolvedValue({ allegroCategoryId: null, method: 'manual' }), + getCategoryParameters: vi.fn().mockResolvedValue({ + parameters: [ + { + id: 'p_stan', + name: 'Stan', + type: 'dictionary', + required: true, + section: 'offer', + restrictions: {}, + dictionary: [{ id: 'nowy', value: 'Nowy' }], + }, + ], + }), + }, + mappings: { + getAllegroCategories: vi + .fn() + .mockResolvedValue([{ id: '12345', name: 'Test Category', parentId: null, leaf: true }]), + }, + }); + renderWithProviders( + , + { apiClient: mockApi }, + ); + + await pickVariantAndAdvance(); + // Offer-details step: no plain-text category field or hint anymore — + // it moved to its own step. + fireEvent.change(await screen.findByLabelText(/^price \(PLN\)$/i), { + target: { value: '99.99' }, + }); + expect(screen.queryByPlaceholderText(/e\.g\. 12345/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category step — pick the leaf via the reused CategoryPicker. + const selectButton = await screen.findByRole('button', { name: /^select$/i }); + fireEvent.click(selectButton); + await waitFor(() => + expect(screen.getByRole('button', { name: /^selected$/i })).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category-parameters step — required "Stan" field blocks Next until filled. + await screen.findByText('Stan'); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/stan is required/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Stan'), { target: { value: 'nowy' } }); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + fireEvent.click(await screen.findByRole('button', { name: /create offer/i })); + + const createOffer = vi.mocked(mockApi.listings.createOffer); + await waitFor(() => expect(createOffer).toHaveBeenCalledTimes(1)); + const [, request] = createOffer.mock.calls[0] as [string, CreateOfferRequest]; + expect(request.overrides?.categoryId).toBe('12345'); + expect(request.overrides?.parameters).toEqual([ + { id: 'p_stan', valuesIds: ['nowy'], section: 'offer' }, + ]); + }); + }); }); diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx index fe56c2199..af0fd519d 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx @@ -31,8 +31,9 @@ * @module features/listings/components/erli */ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { Controller, FormProvider, useForm, type FieldPath } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Link } from 'react-router-dom'; import { Alert } from '../../../../shared/ui/alert'; import { Button } from '../../../../shared/ui/button'; @@ -49,8 +50,21 @@ import { useProductQuery, useProductsQuery, useVariantQuery } from '../../../pro import type { Product, ProductVariant } from '../../../products'; import { useCreateOfferMutation } from '../../hooks/use-create-offer-mutation'; import { useResolveCategoryQuery } from '../../hooks/use-resolve-category-query'; -import type { CreateOfferRequest } from '../../api/listings.types'; -import { erliCreateOfferSchema, type ErliCreateOfferValues } from './erli-create-offer.schema'; +import { useCategoryParametersQuery } from '../../hooks/use-category-parameters-query'; +import { CategoryPicker } from '../CategoryPicker'; +import { CategoryParametersStep } from '../category-parameters-step'; +import { buildParametersZodSchema } from '../build-parameters-zod-schema'; +import { + MissingCategoryParameterSectionError, + categoryParametersToOfferParameters, +} from '../category-parameters-to-offer-parameters'; +import type { CategoryParameterFormValues } from '../category-parameter-form.types'; +import type { CreateOfferRequest, OfferParameter } from '../../api/listings.types'; +import { + erliCreateOfferSchema, + type ErliCreateOfferSubmission, + type ErliCreateOfferValues, +} from './erli-create-offer.schema'; import { ErliDispatchTimeField } from './erli-dispatch-time-field'; import { formatDispatch, @@ -59,10 +73,38 @@ import { } from './erli-offer-fields.schema'; import { readErliOfferRequestPrefill } from './create-erli-offer-request-to-form-values'; -const ERLI_STEP_LABELS = ['Variant', 'Offer details', 'Review'] as const; +/** + * Step labels — capability-conditional (#1384). Erli borrows Allegro's + * category/attribute taxonomy (ADR-023, ADR-031); when the connection has + * Allegro app credentials configured (`config.allegroCategoryAccessEnabled`, + * a per-connection-instance-visible flag — NOT `supportedCapabilities`, + * which is static per-adapterKey and cannot reflect this, see ADR-031 + * "Correction"), the wizard grows a dedicated Category step (CategoryPicker) + * and a Category-parameters step, mirroring `AllegroCreateOfferWizard`. + * Otherwise it keeps today's 3-step shape with the plain-text category field + * folded into "Offer details". + */ +const ERLI_STEP_LABELS_BASIC = ['Variant', 'Offer details', 'Review'] as const; +const ERLI_STEP_LABELS_WITH_CATEGORY = [ + 'Variant', + 'Offer details', + 'Category', + 'Category parameters', + 'Review', +] as const; const VARIANT_SEARCH_DEBOUNCE_MS = 300; const VARIANT_PICKER_PAGE_SIZE = 20; +/** + * RHF cannot infer the dynamic `parameters.{paramId}` path from + * `ErliCreateOfferValues` (the `parameters` slice is `z.record(z.unknown())` + * by design — per-field shapes come from the runtime `CategoryParameter` + * list). Mirrors `AllegroCreateOfferWizard`'s `parametersFieldPath` helper. + */ +function parametersFieldPath(paramId: string): FieldPath { + return `parameters.${paramId}` as FieldPath; +} + function variantLabel(product: Product, variant: ProductVariant): string { const attrs = variant.attributes ? Object.values(variant.attributes).join(' · ') : ''; if (attrs) return `${product.name} — ${attrs}`; @@ -106,7 +148,18 @@ export function ErliCreateOfferWizard({ // Shared, declared-once Erli validator (image gate). Resolved via usePlatform. const erliValidation = usePlatform(connection.platformType)?.offerValidation; - const form = useForm({ + // #1384 — per-connection-instance signal (NOT `connection.supportedCapabilities`, + // which is static per-adapterKey — see ADR-031 "Correction"). Read directly off + // the connection's already-returned `config`, no new query needed. + const allegroCategoryAccessEnabled = connection.config.allegroCategoryAccessEnabled === true; + const stepLabels = allegroCategoryAccessEnabled + ? ERLI_STEP_LABELS_WITH_CATEGORY + : ERLI_STEP_LABELS_BASIC; + const categoryStepIndex = allegroCategoryAccessEnabled ? 2 : null; + const categoryParametersStepIndex = allegroCategoryAccessEnabled ? 3 : null; + const reviewStepIndex = stepLabels.length - 1; + + const form = useForm({ defaultValues: prefill ?? { internalVariantId: defaultVariantId ?? '', variantLabel: '', @@ -118,6 +171,7 @@ export function ErliCreateOfferWizard({ publishImmediately: false, dispatchPeriod: dispatchDefault.period, dispatchUnit: dispatchDefault.unit, + parameters: {}, }, resolver: zodResolver(erliCreateOfferSchema), mode: 'onBlur', @@ -170,6 +224,35 @@ export function ErliCreateOfferWizard({ } }, [categoryQuery.data, form]); + // #1384 — category-parameter schema, only fetched once a category is + // selected AND the connection has Allegro category access configured. + // Same hook Allegro's wizard uses (`useCategoryParametersQuery`) — no new + // hook needed, per the plan. + const currentCategoryId = form.watch('categoryId'); + const categoryParametersQuery = useCategoryParametersQuery( + allegroCategoryAccessEnabled ? connection.id : undefined, + allegroCategoryAccessEnabled ? currentCategoryId || undefined : undefined, + ); + const categoryParameters = useMemo( + () => categoryParametersQuery.data ?? [], + [categoryParametersQuery.data], + ); + // Clear the parameters slice whenever the chosen category changes — the + // shape is category-specific so prior values would never be valid under a + // new schema (mirrors `AllegroCreateOfferWizard`). + const lastCategoryIdRef = useRef(currentCategoryId); + useEffect(() => { + if (lastCategoryIdRef.current && lastCategoryIdRef.current !== currentCategoryId) { + form.setValue('parameters', {}, { shouldDirty: false, shouldValidate: false }); + form.clearErrors('parameters'); + } + lastCategoryIdRef.current = currentCategoryId; + }, [currentCategoryId, form]); + // #423 — surfaces `MissingCategoryParameterSectionError` (stale cache + // predating #417) with an actionable "reload" message, mirroring Allegro's + // wizard. + const [staleSchemaError, setStaleSchemaError] = useState(null); + useEffect(() => { function handleBeforeUnload(event: BeforeUnloadEvent): void { if (!form.formState.isDirty) return; @@ -187,10 +270,10 @@ export function ErliCreateOfferWizard({ () => erliValidation?.validateRow({ imageCount: imageUrls.length, - needsProductParameters: false, + needsProductParameters: allegroCategoryAccessEnabled, willLinkProductCard: false, }) ?? [], - [erliValidation, imageUrls.length], + [erliValidation, imageUrls.length, allegroCategoryAccessEnabled], ); const hasImageBlocker = pickedProduct !== null && imageBlockers.length > 0; @@ -207,19 +290,47 @@ export function ErliCreateOfferWizard({ setPickedVariantEan(variant.ean ?? variant.gtin ?? null); } - const STEP_FIELDS: ReadonlyArray> = [ - ['internalVariantId'], - ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], - [], - ]; + const STEP_FIELDS: ReadonlyArray> = allegroCategoryAccessEnabled + ? [ + ['internalVariantId'], + ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], + [], // Category — CategoryPicker, no static-field trigger + [], // Category parameters — validated dynamically below + [], + ] + : [['internalVariantId'], ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], []]; async function goNext(): Promise { const fields = STEP_FIELDS[stepIndex] ?? []; const valid = fields.length === 0 ? true : await form.trigger(fields); if (!valid) return; if (stepIndex === 1 && hasImageBlocker) return; // can't advance with a missing image + + // #1384 — dynamic per-category Zod validation, mirrors + // `AllegroCreateOfferWizard`'s Step-3 gate. Skipped when the category has + // no parameters or the schema is still loading. + if ( + allegroCategoryAccessEnabled && + stepIndex === categoryParametersStepIndex && + categoryParameters.length > 0 + ) { + const paramValues = + (form.getValues('parameters') as CategoryParameterFormValues | undefined) ?? {}; + const result = buildParametersZodSchema(categoryParameters).safeParse(paramValues); + if (!result.success) { + form.clearErrors('parameters'); + for (const issue of result.error.issues) { + const paramId = String(issue.path[0] ?? ''); + if (paramId === '') continue; + form.setError(parametersFieldPath(paramId), { type: 'manual', message: issue.message }); + } + return; + } + form.clearErrors('parameters'); + } + setCompletedSteps((prev) => new Set(prev).add(stepIndex)); - setStepIndex((i) => Math.min(i + 1, ERLI_STEP_LABELS.length - 1)); + setStepIndex((i) => Math.min(i + 1, stepLabels.length - 1)); } function goBack(): void { @@ -236,6 +347,28 @@ export function ErliCreateOfferWizard({ // the `defaultVariantId` path where no product was interactively picked. if (imageBlockers.length > 0) return; + setStaleSchemaError(null); // clear from any prior submit + + // #1384 — serialize the Step-4 category-parameter values into the + // neutral `OfferParameter[]` shape, exactly like Allegro's wizard. + // `OfferBuilderService.buildOfferParameters` merges `overrides.parameters` + // server-side unchanged — no BE change needed (confirmed in ADR-031's plan). + let parameters: OfferParameter[] = []; + if (allegroCategoryAccessEnabled) { + try { + parameters = categoryParametersToOfferParameters( + (submitted.parameters as CategoryParameterFormValues | undefined) ?? {}, + categoryParameters, + ); + } catch (error) { + if (error instanceof MissingCategoryParameterSectionError) { + setStaleSchemaError(error.parameterName); + return; + } + throw error; + } + } + const request: CreateOfferRequest = { internalVariantId: submitted.internalVariantId, stock: submitted.stock, @@ -246,6 +379,7 @@ export function ErliCreateOfferWizard({ ...(submitted.categoryId ? { categoryId: submitted.categoryId } : {}), description: submitted.description ? submitted.description : null, imageUrls, + ...(parameters.length > 0 ? { parameters } : {}), platformParams: { dispatchTime: { period: submitted.dispatchPeriod, unit: submitted.dispatchUnit }, }, @@ -291,7 +425,7 @@ export function ErliCreateOfferWizard({ @@ -304,6 +438,22 @@ export function ErliCreateOfferWizard({ {mutation.error.message} ) : null} + {staleSchemaError !== null ? ( + +

+ Category parameter {staleSchemaError} is missing data that was added + in a recent update. Please reload this page to refetch the latest category schema. +

+
+ + +
+
+ ) : null}
- - - + {!allegroCategoryAccessEnabled ? ( + <> + + + +

+ Add Allegro category browsing to this connection to pick from a list instead + of typing a raw category id. Configure category browsing. +

+ + ) : null}
) : null} - {stepIndex === 2 ? ( + {allegroCategoryAccessEnabled && stepIndex === categoryStepIndex ? ( +
+ + Category + + ( + + )} + /> +

+ Browse the Allegro-borrowed category tree (ADR-023/ADR-031) and pick a leaf + category. +

+
+ ) : null} + + {allegroCategoryAccessEnabled && stepIndex === categoryParametersStepIndex ? ( + categoryParametersQuery.isLoading ? ( +

+ Loading category parameters… +

+ ) : categoryParametersQuery.error ? ( + + {categoryParametersQuery.error.message} + + + ) : categoryParameters.length === 0 ? ( +

No additional parameters required for this category.

+ ) : ( + + ) + ) : null} + + {stepIndex === reviewStepIndex ? (
Variant
{values.variantLabel || values.internalVariantId}
@@ -499,7 +706,7 @@ export function ErliCreateOfferWizard({ ← Back ) : null} - {stepIndex < ERLI_STEP_LABELS.length - 1 ? ( + {stepIndex < stepLabels.length - 1 ? ( +
+ + ) : null} +
-
) : ( - )} From be20988048ba02ea8bb30391155af577ae9ca22c Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 7 Jul 2026 22:18:11 +0200 Subject: [PATCH 2/4] fix(erli,web): address PR #1401 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate `canSubmit` in ErliCredentialsPanel on `allegroEnabled` so unchecking the Allegro-access box after typing (but not saving) Client ID/Secret disables Save instead of showing a false-positive "Credentials saved" toast while discarding the typed secret. - Drop the checkbox's invalid inline `accentColor: var(--accent)` (token doesn't exist) — `index.css` already applies `accent-color: var(--accent-primary)` globally. - Restore category-parameter values on Erli offer retry by reusing the Allegro retry mapper's `readParameters` heuristic (exported) instead of always prefilling an empty `parameters` object — both platforms persist the same neutral `overrides.parameters` shape. Signed-off-by: norbert-kulus-blockydevs --- .../create-offer-request-to-form-values.ts | 7 ++++++- ...e-erli-offer-request-to-form-values.test.ts | 18 ++++++++++++++++++ ...create-erli-offer-request-to-form-values.ts | 12 +++++------- .../components/erli-credentials-panel.test.tsx | 18 ++++++++++++++++++ .../erli/components/erli-credentials-panel.tsx | 5 +++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts index b7f913e56..a5c3ca2e8 100644 --- a/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts @@ -53,8 +53,13 @@ function readString(params: Record | undefined, key: string): s * (product). The form-state map is keyed by parameter id alone — re-submission * re-derives the section split from the freshly-loaded category-parameters * metadata, so the section distinction is not preserved on the form side. + * + * Exported for reuse by the Erli retry mapper (#1384) — both platforms + * persist the same neutral `overrides.parameters` wire shape. */ -function readParameters(overrides: CreateOfferOverrides | undefined): CategoryParameterFormValues { +export function readParameters( + overrides: CreateOfferOverrides | undefined, +): CategoryParameterFormValues { const out: CategoryParameterFormValues = {}; appendWireParameters(out, overrides?.parameters); // Transitional fallback for snapshots persisted before #1071. diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts index e3294b746..08fd8b8d6 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts @@ -55,6 +55,24 @@ describe('createErliOfferRequestToFormValues', () => { expect(values.dispatchPeriod).toBe(2); expect(values.dispatchUnit).toBe('day'); }); + + it('restores category-parameter values from the wire snapshot on retry', () => { + const values = createErliOfferRequestToFormValues( + snapshot({ + overrides: { + title: 'With parameters', + categoryId: '12345', + parameters: [ + { id: 'p1', values: ['Red'], section: 'offer' }, + { id: 'p2', valuesIds: ['v1', 'v2'], section: 'offer' }, + ], + }, + }), + FALLBACK, + ); + + expect(values.parameters).toEqual({ p1: 'Red', p2: ['v1', 'v2'] }); + }); }); describe('readErliOfferRequestPrefill', () => { diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts index f73c95bfd..3d75bfc24 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts @@ -26,6 +26,7 @@ import { SUPPORTED_OFFER_CREATION_REQUEST_SCHEMA_VERSION, type CreateOfferRequest, } from '../../api/listings.types'; +import { readParameters } from '../create-offer-request-to-form-values'; import type { ErliCreateOfferValues } from './erli-create-offer.schema'; import { isValidDispatch, type ErliDispatchTimeParam } from './erli-offer-fields.schema'; @@ -62,13 +63,10 @@ export function createErliOfferRequestToFormValues( publishImmediately: request.publishImmediately, dispatchPeriod: dispatch.period, dispatchUnit: dispatch.unit, - // #1384 — category-parameter values are not reconstructed from the wire - // snapshot on retry (parity gap, same simplification the schema's - // `.default({})` already assumes for a fresh wizard); the operator - // re-fills the Category-parameters step if the category access flag is - // active. `categoryId` above is restored, so the step re-fetches the - // right schema. - parameters: {}, + // Reuses the Allegro retry mapper's heuristic (#1384) — both platforms + // persist the same neutral `overrides.parameters` wire shape, so a + // retried offer re-opens with its previously entered parameter values. + parameters: readParameters(overrides), }; } diff --git a/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx b/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx index 92e2a51e4..de9541f9d 100644 --- a/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx +++ b/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx @@ -187,6 +187,24 @@ describe('ErliCredentialsPanel', () => { expect(updateCredentials).not.toHaveBeenCalled(); }); + it('disables save after typing a Client ID then unchecking the box (no discarded-input false success)', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const update = vi.fn().mockResolvedValue(erliConnection); + const apiClient = createMockApiClient({ connections: { updateCredentials, update } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.click(screen.getByRole('checkbox', { name: /browse allegro categories/i })); + fireEvent.change(screen.getByPlaceholderText('Allegro Client ID'), { + target: { value: 'client-123' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: /browse allegro categories/i })); + + expect(screen.getByRole('button', { name: 'Save credentials' })).toBeDisabled(); + expect(updateCredentials).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + it('blocks submit when enabling the checkbox without ever entering credentials', () => { renderWithProviders(); fireEvent.click(screen.getByText('Rotate API key')); diff --git a/apps/web/src/plugins/erli/components/erli-credentials-panel.tsx b/apps/web/src/plugins/erli/components/erli-credentials-panel.tsx index 8ba75b23d..baa41b27b 100644 --- a/apps/web/src/plugins/erli/components/erli-credentials-panel.tsx +++ b/apps/web/src/plugins/erli/components/erli-credentials-panel.tsx @@ -75,7 +75,9 @@ export function ErliCredentialsPanel({ connection }: { connection: Connection }) const idFilled = allegroClientId.trim().length > 0; const secretFilled = allegroClientSecret.trim().length > 0; const canSubmit = - apiKey.trim().length > 0 || idFilled || secretFilled || allegroEnabled !== initialAllegroEnabled; + apiKey.trim().length > 0 || + (allegroEnabled && (idFilled || secretFilled)) || + allegroEnabled !== initialAllegroEnabled; function openPanel(): void { // Seed the checkbox from the connection's current server-confirmed state @@ -180,7 +182,6 @@ export function ErliCredentialsPanel({ connection }: { connection: Connection }) {allegroEnabled ? (
- setAllegroClientId(event.target.value)} - /> -
+
setAllegroClientSecret(event.target.value)} + placeholder="Allegro Client ID" + value={allegroClientId} + onChange={(event) => setAllegroClientId(event.target.value)} /> - + + From an Allegro app you register at apps.developer.allegro.pl. Used only to read + the public category catalog - never to sign in as a seller or place offers. + +
+
+
+ setAllegroClientSecret(event.target.value)} + /> + +
+ + Stored encrypted. You won't see it again after saving. +
) : null} From 4e75b300f8f12ce03243123654cb57a79a0088dc Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 7 Jul 2026 23:10:15 +0200 Subject: [PATCH 4/4] test(erli,docs): cover mutation-sequencing failure paths + close ADR-031 documentation gaps (#1401 third review) - erli-credentials-panel.test.tsx: add regression tests asserting the config patch never fires when the credentials write rejects, and that a config-patch rejection after a successful credentials write leaves the flag/fields intact with a retryable inline error - the two claims the PR description makes about "the trickiest part of this issue" were previously unverified by the suite. - ADR-031: add a second Correction noting the sequencing is two FE-orchestrated mutations, not a single backend write, since no endpoint accepts both the credential pair and the config flag together; also record the wizard's 3-step-vs-5-step topology and the deferred credential-verification indicator as explicit, confirmed scope cuts rather than implicit deviations from the approved mockup. Co-Authored-By: Claude Sonnet 5 Signed-off-by: norbert-kulus-blockydevs --- .../erli-credentials-panel.test.tsx | 53 +++++++++++++++++++ ...category-catalog-via-client-credentials.md | 4 ++ 2 files changed, 57 insertions(+) diff --git a/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx b/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx index de9541f9d..1f52ecd99 100644 --- a/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx +++ b/apps/web/src/plugins/erli/components/erli-credentials-panel.test.tsx @@ -216,4 +216,57 @@ describe('ErliCredentialsPanel', () => { screen.getByText(/enter the allegro client id and client secret to enable/i), ).toBeInTheDocument(); }); + + it('never fires the config patch when the credentials write rejects', async () => { + const updateCredentials = vi.fn().mockRejectedValue(new Error('Invalid Allegro credentials')); + const update = vi.fn().mockResolvedValue(erliConnectionWithAllegroAccess); + const apiClient = createMockApiClient({ connections: { updateCredentials, update } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.click(screen.getByRole('checkbox', { name: /browse allegro categories/i })); + fireEvent.change(screen.getByPlaceholderText('Allegro Client ID'), { + target: { value: 'client-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('Allegro Client Secret'), { + target: { value: 'secret-456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save credentials' })); + + await waitFor(() => { + expect(updateCredentials).toHaveBeenCalledTimes(1); + }); + expect(update).not.toHaveBeenCalled(); + expect(await screen.findByText('Invalid Allegro credentials')).toBeInTheDocument(); + // Panel stays open with the entered fields intact so a retry can resend them. + expect(screen.getByPlaceholderText('Allegro Client ID')).toHaveValue('client-123'); + }); + + it('keeps allegroCategoryAccessEnabled at its prior value and shows an inline error when the config patch rejects after credentials succeeded', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const update = vi.fn().mockRejectedValue(new Error('Connection update failed')); + const apiClient = createMockApiClient({ connections: { updateCredentials, update } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.click(screen.getByRole('checkbox', { name: /browse allegro categories/i })); + fireEvent.change(screen.getByPlaceholderText('Allegro Client ID'), { + target: { value: 'client-123' }, + }); + fireEvent.change(screen.getByPlaceholderText('Allegro Client Secret'), { + target: { value: 'secret-456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save credentials' })); + + await waitFor(() => { + expect(update).toHaveBeenCalledTimes(1); + }); + expect( + await screen.findByText(/category-browsing setting failed to save/i), + ).toBeInTheDocument(); + // Panel stays open (flag write failed, so it never got a chance to reflect the + // new value anywhere the operator can see) and the fields aren't discarded. + expect(screen.getByPlaceholderText('Allegro Client ID')).toHaveValue('client-123'); + expect(screen.queryByText('Credentials saved')).not.toBeInTheDocument(); + }); }); diff --git a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md index 10228a23c..89a40e3e1 100644 --- a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md +++ b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md @@ -18,6 +18,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory **Correction (found during #1383 implementation)**: the connection-list DTO's `supportedCapabilities` field is populated from the static, per-`adapterKey` `AdapterMetadata.supportedCapabilities` declared at plugin registration — it is **not** computed by resolving each connection's live adapter instance and running the `is*` guards. It therefore cannot differ between two Erli connections regardless of whether either has Allegro app credentials configured, and the bulk-wizard's `connection.supportedCapabilities.includes('CategoryBrowser')` gate (#1367) is consequently **unaffected** by this feature (it stays `false` for Erli either way — a pre-existing bulk-wizard limitation, unchanged, and out of scope here per the plan's non-goals). The frontend single-offer wizard therefore needs its own, per-connection-instance-visible signal: a new non-secret `ErliConnectionConfig.allegroCategoryAccessEnabled?: boolean` field, written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`, and read directly by the FE from the connection's `config` (already returned verbatim by the generic connection-read endpoints — no DTO changes needed). This keeps the mechanism entirely inside Erli's own plugin-local config shape; it does not touch the generic capability system. +**Second correction (found during #1401 review)**: the sentence above — "written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`" — does not hold in the shipped implementation. No single backend endpoint accepts both the Allegro credential pair and `config.allegroCategoryAccessEnabled` in one request; `PUT /connections/:id/credentials` and `PATCH /connections/:id` (config) remain independent, generic, single-purpose routes for every platform, with zero cross-write coupling. `ErliCredentialsPanel` instead sequences two **FE-orchestrated** mutations from one Save click — credentials first, then the config flag — ordered to fail safe (credentials-write failure skips the config patch entirely; a config-patch failure after a successful credentials write leaves the flag at its prior value and surfaces an inline retryable error). See the component's module header and the PR #1401 description's "Atomicity design decision" section for the full analysis. This ADR's Decision section is corrected accordingly. + ## Alternatives considered - **Require a real Allegro seller `Connection`, resolve its taxonomy from there**: rejected — forces Erli-only operators to create and maintain an unrelated Allegro seller account/connection just to browse a public catalog; also reintroduces the "which of N Allegro connections is the owner" ambiguity noted in `category-mapping.repository.ts`'s existing "Ambiguous borrowed-taxonomy category mapping" log. @@ -37,6 +39,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory - Small, self-contained duplication of OAuth `client_credentials` + category-fetch logic between the Allegro and Erli packages (~100-150 lines) rather than one shared implementation. - Per-connection Allegro app credentials mean each operator manages their own Allegro Developer Portal app registration (extra setup step, mitigated by the optional/recommended framing in the connection wizard). - Category/parameter data is genuinely seller-independent, so a future multi-tenant SaaS deployment may still want the shared-credential alternative — deferred, not precluded, by this design. +- **FE wizard step topology deviates from the approved mockup, deliberately** (flagged in the #1401 review, confirmed here as an accepted scope cut, not an oversight): the mockup keeps a constant 5-step shape, with the Category step itself rendering the plain-text fallback when Allegro access is off. The shipped `ErliCreateOfferWizard` instead collapses to 3 steps in that state, folding category entry into "Offer details." Functionally equivalent, fully tested, and simpler to reason about than a step that changes shape in place — but explicitly noted here so it isn't re-discovered as an unstated deviation. +- **No credential-verification status indicator on save** (also flagged in the #1401 review, confirmed as an accepted scope cut): the mockup shows a live "Not verified yet" / "Verification failed" badge and `aria-invalid` on the Allegro Client ID/Secret fields. This is deferred because there is no synchronous Allegro credential-verification call in the backend today — the `client_credentials` token exchange only happens lazily, on the first category-catalog fetch, so the FE has nothing to poll or await at save time. Tracked as a fast-follow candidate for once a synchronous verification endpoint exists (would need its own issue). **Migration path (if applicable):** - Purely additive: existing Erli connections without `allegroClientId`/`allegroClientSecret` keep today's plain-text category-ID behavior unchanged.