diff --git a/packages/app-elements/package.json b/packages/app-elements/package.json index 462b67e86..ab7edfff8 100644 --- a/packages/app-elements/package.json +++ b/packages/app-elements/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@commercelayer/js-auth": "^7.4.2", + "@commercelayer/provisioning-sdk": "2.10.2", "@commercelayer/sdk": "8.0.0-beta.11", "@date-fns/tz": "^1.5.0", "@monaco-editor/react": "~4.7.0", diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 7516bee7e..6103c1f07 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -457,6 +457,7 @@ export { refreshResourceLists, removeFromResourceLists, type UseResourceListConfig, + type UseResourceListReturn, useResourceList, } from "#ui/resources/useResourceList" export { diff --git a/packages/app-elements/src/ui/atoms/CodeBlock.tsx b/packages/app-elements/src/ui/atoms/CodeBlock.tsx index 17f6ca9ff..976e228ae 100644 --- a/packages/app-elements/src/ui/atoms/CodeBlock.tsx +++ b/packages/app-elements/src/ui/atoms/CodeBlock.tsx @@ -50,7 +50,10 @@ export const CodeBlock = withSkeletonTemplate( return ( -
+ {/* A shade darker only on a gray overlay, where `bg-gray-50` would + disappear into the surface. A white overlay — every drawer, unless it + asks for `backgroundColor="light"` — keeps the page's own shade. */} +
{ + /** + * The class is a contract with the blocks rendered on the surface: `CodeBlock` + * reads it to go a shade darker, because its own `bg-gray-50` would be + * invisible on a gray-50 overlay. A white overlay must not carry it. + */ + test("Should announce a light surface, so blocks on it can darken", () => { + const { getByTestId } = render( + + secret + , + ) + + expect(getByTestId("overlay").className).toContain( + "overlay-container-light", + ) + }) + + test("Should not announce it when the overlay is white", () => { + const { getByTestId } = render( + {}}> + secret + , + ) + + expect(getByTestId("overlay").className).not.toContain( + "overlay-container-light", + ) + }) +}) + describe("Overlay body scroll lock", () => { beforeEach(() => { document.body.style.overflow = "" diff --git a/packages/app-elements/src/ui/internals/Overlay.tsx b/packages/app-elements/src/ui/internals/Overlay.tsx index 4e4da4945..96e9b9efa 100644 --- a/packages/app-elements/src/ui/internals/Overlay.tsx +++ b/packages/app-elements/src/ui/internals/Overlay.tsx @@ -145,7 +145,9 @@ export const Overlay: React.FC = ({ "overlay-container", "fixed z-50 h-full overflow-y-auto outline-hidden", { - "bg-gray-50": backgroundColor === "light", + // the class is what a block sitting on this surface reads to pick its + // own shade: `bg-gray-50` alone is invisible on a gray-50 overlay + "bg-gray-50 overlay-container-light": backgroundColor === "light", "bg-white": backgroundColor == null, "inset-0 w-full": !drawer, // Full width on mobile. There used to be a 95vw max-width here, but it diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx index 751c1af75..20b2c4f7e 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx @@ -343,15 +343,27 @@ function ResourceListComponent({ metricsQuery, type, query, + filters, paginationType = "infinite", preProcess, ...listProps -}: UseResourceListConfig & { +}: Omit, "query"> & { paginationType?: "infinite" | "pagination" + query?: Omit< + NonNullable["query"]>, + "filters" + > + /** The filters the bar computed, merged into the query below. */ + filters?: QueryFilter } & ResourceListProps): JSX.Element { const result = useResourceList({ type, - query, + // the cast is nameable here, where the resource type is in scope: a query + // spread produces a fresh object type that a deferred lookup will not accept + query: { + ...query, + filters, + } as UseResourceListConfig["query"], metricsQuery, paginationType, preProcess, @@ -427,10 +439,10 @@ const makeFilteredList: (options: { ? undefined : (resourceListProps.title ?? t("common.all")) } - query={{ - ...query, - filters: sdkFilters, - }} + query={query} + // merged into the query by the component below, where the resource type is + // in scope and the merge can be typed + filters={sdkFilters} metricsQuery={ metricsQuery == null ? undefined @@ -448,6 +460,7 @@ function ResourceTableComponent({ type, columns, query, + filters, metricsQuery, preProcess, paginationType = "pagination", @@ -458,11 +471,23 @@ function ResourceTableComponent({ onSortChange, defaultSort, ...tableProps -}: UseResourceTableConfig & ResourceTableProps): JSX.Element { +}: Omit, "query"> & { + query?: Omit< + NonNullable["query"]>, + "filters" + > + /** The filters the bar computed, merged into the query below. */ + filters?: QueryFilter +} & ResourceTableProps): JSX.Element { const { ResourceTable, Pagination } = useResourceTable({ type, columns, - query, + // as in `ResourceListComponent`: the merge is typed here, where the resource + // type is in scope + query: { + ...query, + filters, + } as UseResourceTableConfig["query"], metricsQuery, preProcess, paginationType, @@ -502,10 +527,10 @@ const makeFilteredTable: (options: { title={ hideTitle === true ? undefined : (tableProps.title ?? t("common.all")) } - query={{ - ...query, - filters: sdkFilters, - }} + query={query} + // merged into the query by the component below, where the resource type is + // in scope and the merge can be typed + filters={sdkFilters} metricsQuery={ metricsQuery == null ? undefined diff --git a/packages/app-elements/src/ui/resources/useResourceList/apiFlavour.ts b/packages/app-elements/src/ui/resources/useResourceList/apiFlavour.ts new file mode 100644 index 000000000..9a5f0628b --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceList/apiFlavour.ts @@ -0,0 +1,133 @@ +/** + * Which Commerce Layer API a list speaks to, and the types that follow from it. + * + * The Core and Provisioning SDKs are structurally parallel where a list touches + * them — same `ListableResourceType` / `ResourceFields` / `ResourceSortFields` / + * `QueryParamsList` names, a `client[type].list({ ...query, pageNumber })` call, and + * a `meta` of `pageCount / recordCount / currentPage / recordsPerPage`. So a list + * needs no new logic to serve both, only a map from the flavour to each SDK's types. + * + * Both SDKs are imported for their types only. A runtime import of either would be + * bundled into every app that renders a list, so keep every import in this file + * `import type`. + * + * See `docs/adr/0001-provisioning-api-in-resource-list.md` for why the Provisioning + * client is passed in by the caller rather than built here. + */ + +import type { + CommerceLayerProvisioningClient, + ListableResourceType as ProvisioningListableResourceType, + QueryParamsList as ProvisioningQueryParamsList, + ResourceFields as ProvisioningResourceFields, + ResourceSortFields as ProvisioningResourceSortFields, +} from "@commercelayer/provisioning-sdk" +import type { + CommerceLayerBundle, + ListableResourceType as CoreListableResourceType, + QueryParamsList as CoreQueryParamsList, + ResourceFields as CoreResourceFields, + ResourceSortFields as CoreResourceSortFields, +} from "@commercelayer/sdk" + +export type { ProvisioningListableResourceType } + +/** The API a list speaks to. Defaults to `core` wherever it is optional. */ +export type ApiFlavour = "core" | "provisioning" + +/** + * Any resource type either API can list. + * + * The conditional below distributes over the `ApiFlavour` union, so this is Core's + * listable union plus Provisioning's. Used where code holds a resource type without + * knowing its flavour — a signal key, or the metrics guard — and a plain `string` + * would give up typo checking. + */ +export type AnyListableResourceType = ListableResourceTypeFor + +/** The resource types that flavour can list. */ +export type ListableResourceTypeFor = + TApi extends "provisioning" + ? ProvisioningListableResourceType + : CoreListableResourceType + +/** The SDK client that flavour is reached through. */ +export type ClientFor = TApi extends "provisioning" + ? CommerceLayerProvisioningClient + : CommerceLayerBundle + +/** + * Per-flavour maps from resource type to what that resource is. + * + * Written as mapped types indexed by the resource, rather than as nested + * conditionals: a deferred indexed access (`CoreResources[TResource]`) stays + * assignable in both directions while `TResource` is still generic, whereas a + * conditional does not reduce until it is resolved — which made every caller that + * builds a query (the filters stack) fail to typecheck. + */ +type CoreResources = { + [K in CoreListableResourceType]: Awaited< + ReturnType + >[number] +} + +type ProvisioningResources = { + [K in ProvisioningListableResourceType]: Awaited< + ReturnType + >[number] +} + +type CoreQueries = { + [K in CoreListableResourceType]: Omit< + CoreQueryParamsList, + "pageNumber" + > +} + +type ProvisioningQueries = { + [K in ProvisioningListableResourceType]: Omit< + ProvisioningQueryParamsList, + "pageNumber" + > +} + +type CoreSortables = { + [K in CoreListableResourceType]: Extract< + keyof CoreResourceSortFields[K], + string + > +} + +type ProvisioningSortables = { + [K in ProvisioningListableResourceType]: Extract< + keyof ProvisioningResourceSortFields[K], + string + > +} + +/** One record of `TResource`, as that flavour's SDK returns it. */ +export type ResourceFor< + TApi extends ApiFlavour, + TResource extends ListableResourceTypeFor, +> = TApi extends "provisioning" + ? ProvisioningResources[TResource & ProvisioningListableResourceType] + : CoreResources[TResource & CoreListableResourceType] + +/** The list query that flavour accepts, minus the page the list itself drives. */ +export type QueryParamsListFor< + TApi extends ApiFlavour, + TResource extends ListableResourceTypeFor, +> = TApi extends "provisioning" + ? ProvisioningQueries[TResource & ProvisioningListableResourceType] + : CoreQueries[TResource & CoreListableResourceType] + +/** + * The attributes that flavour's API can sort `TResource` by — the single source of + * truth for whether a column may be sortable, since the API rejects anything else. + */ +export type SortableAttributeFor< + TApi extends ApiFlavour, + TResource extends ListableResourceTypeFor, +> = TApi extends "provisioning" + ? ProvisioningSortables[TResource & ProvisioningListableResourceType] + : CoreSortables[TResource & CoreListableResourceType] diff --git a/packages/app-elements/src/ui/resources/useResourceList/index.tsx b/packages/app-elements/src/ui/resources/useResourceList/index.tsx index 0d13a586e..fe425420d 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/index.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/index.tsx @@ -6,5 +6,6 @@ export { type ResourceListItemTemplateProps, type ResourceListProps, type UseResourceListConfig, + type UseResourceListReturn, useResourceList, } from "./useResourceList" diff --git a/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts index 3ad3808f1..8e5bb782b 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts @@ -1,10 +1,15 @@ import type { CommerceLayerBundle, ListableResourceType, - QueryParamsList, - ResourceFields, } from "@commercelayer/sdk" import uniqBy from "lodash-es/uniqBy" +import type { + ApiFlavour, + ClientFor, + ListableResourceTypeFor, + QueryParamsListFor, + ResourceFor, +} from "./apiFlavour" import { isValidMetricsResource, type MetricsApiClient, @@ -18,6 +23,35 @@ type ListResource = Awaited< export type Resource = ListResource[number] +/** + * The part of an SDK client a list actually touches. Both the Core and the + * Provisioning client match it, which is what lets one fetcher serve both. + */ +interface SdkListResource { + list: ( + params: Record, + ) => Promise & { meta: FetcherResponse["meta"] }> +} + +/** + * The `client.orders` / `client.roles` accessor for a resource type. + * + * Indexing either SDK's client by a generic resource type defeats its types + * ("union type too complex to represent"), so the lookup is made against the shape + * both clients share. The resource type is constrained to a listable one of the + * flavour in use, so the accessor is always there. + */ +function listResourceOf( + client: unknown, + resourceType: string, +): SdkListResource { + // the cast past `noUncheckedIndexedAccess`: a listable resource type always has + // its accessor on the client of the flavour it belongs to + return (client as Record>)[ + resourceType + ] as SdkListResource +} + export interface FetcherResponse { list: TResource[] meta: { @@ -29,7 +63,10 @@ export interface FetcherResponse { } } -export async function listFetcher({ +export async function listFetcher< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +>({ currentData, resourceType, client, @@ -39,7 +76,7 @@ export async function listFetcher({ pageNumber, cursor, }: { - currentData?: FetcherResponse> + currentData?: FetcherResponse> resourceType: TResource mode?: "infinite" | "pagination" pageNumber?: number @@ -53,14 +90,23 @@ export async function listFetcher({ | { client: CommerceLayerBundle clientType: "coreSdkClient" - query?: Omit, "pageNumber"> + query?: QueryParamsListFor } | { client: MetricsApiClient clientType: "metricsClient" query: Record> } -)): Promise>> { + | { + /** + * Provisioning API. The client is built by the caller — app-elements has no + * provisioning token — and is otherwise used exactly like the core one. + */ + client: ClientFor<"provisioning"> + clientType: "provisioningSdkClient" + query?: QueryParamsListFor + } +)): Promise>> { const currentPage = currentData?.meta.currentPage ?? 0 const pageToFetch = mode === "pagination" && pageNumber != null ? pageNumber : currentPage + 1 @@ -71,7 +117,7 @@ export async function listFetcher({ const listResponse = clientType === "metricsClient" - ? await client.list(resourceType as MetricsResources, { + ? await client.list(resourceType as unknown as MetricsResources, { ...query, search: { ...query.search, @@ -84,23 +130,34 @@ export async function listFetcher({ : (currentData?.meta.cursor ?? null), }, }) - : // @ts-expect-error "Expression produces a union type that is too complex to represent" - await client[resourceType].list({ + : await listResourceOf>( + client, + resourceType, + ).list({ ...query, pageNumber: pageToFetch, }) - // we need the primitive array - // without the sdk added methods ('meta' | 'first' | 'last' | 'get') + // the primitive array, without the methods every SDK adds to its list response + // ('meta' | 'first' | 'last' | 'get'), and typed as this flavour's resource: + // each client returns its own shape, but from here on they are all the same list + const fetchedList = [...listResponse] as Array> const existingList = currentData?.list ?? [] // In pagination mode, replace the list instead of accumulating const uniqueList = mode === "pagination" - ? [...listResponse] - : uniqBy(existingList.concat(listResponse), "id") - // The core SDK's `meta.cursor` is an object we don't use here; keep only the - // string cursor set by the metrics client for infinite scrolling. - const { cursor: responseCursor, ...rest } = listResponse.meta + ? fetchedList + : uniqBy(existingList.concat(fetchedList), "id") + // The core SDK's `meta.cursor` is an object we don't use here, and the + // provisioning one has no cursor at all; keep only the string cursor set by the + // metrics client for infinite scrolling. + const { cursor: responseCursor, ...rest } = listResponse.meta as { + pageCount: number + recordCount: number + currentPage: number + recordsPerPage: number + cursor?: unknown + } const meta = { ...rest, cursor: typeof responseCursor === "string" ? responseCursor : null, diff --git a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts index 4c6508d1b..ab39f2b31 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts @@ -1,8 +1,4 @@ -import type { - ListableResourceType, - ListMeta, - ListResponse, -} from "@commercelayer/sdk" +import type { ListMeta, ListResponse } from "@commercelayer/sdk" import castArray from "lodash-es/castArray" import { useMemo } from "react" import type { Writable } from "type-fest" @@ -12,6 +8,7 @@ import { adaptMetricsOrderToCore, type MetricsResourceOrder, } from "./adaptMetricsOrderToCore" +import type { AnyListableResourceType } from "./apiFlavour" import type { Resource } from "./listFetcher" export type MetricsResources = "orders" | "returns" @@ -161,7 +158,9 @@ export function useMetricsSdkProvider(): { } export function isValidMetricsResource( - resourceType: ListableResourceType, + // either flavour's listable types: the caller may hold a resource type without + // knowing which API it belongs to, but a name outside both unions is still a typo + resourceType: AnyListableResourceType, ): resourceType is MetricsResources { return ["orders", "returns"].includes(resourceType) } diff --git a/packages/app-elements/src/ui/resources/useResourceList/provisioningApi.test.tsx b/packages/app-elements/src/ui/resources/useResourceList/provisioningApi.test.tsx new file mode 100644 index 000000000..6338a79aa --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceList/provisioningApi.test.tsx @@ -0,0 +1,204 @@ +import { render, waitFor } from "@testing-library/react" +import type { FC } from "react" +import { CoreSdkProvider } from "#providers/CoreSdkProvider" +import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" +import type { ClientFor } from "./apiFlavour" +import { listFetcher } from "./listFetcher" +import { useResourceList, useResourceListForApi } from "./useResourceList" + +/** + * A stand-in for the Provisioning client. The real one is built by the caller from + * the dashboard's own token (see + * `docs/adr/0001-provisioning-api-in-resource-list.md`), so a list is handed a + * client rather than reaching for one — which is exactly what makes it testable + * without a network or a provider. + */ +function fakeProvisioningClient({ + pages, +}: { + pages: Array> +}): { + client: ClientFor<"provisioning"> + calls: Array> +} { + const calls: Array> = [] + + const list = async ( + params: Record, + ): Promise> => { + calls.push(params) + const pageNumber = Number(params.pageNumber ?? 1) + const records = pages[pageNumber - 1] ?? [] + const listResponse = [...records] as Array<{ id: string; name: string }> & { + meta: { + pageCount: number + recordCount: number + currentPage: number + recordsPerPage: number + } + } + // the shape the Provisioning SDK returns: an array carrying its own `meta` + listResponse.meta = { + pageCount: pages.length, + recordCount: pages.flat().length, + currentPage: pageNumber, + recordsPerPage: records.length, + } + return listResponse + } + + return { + client: { roles: { list } } as unknown as ClientFor<"provisioning">, + calls, + } +} + +describe("listFetcher, provisioning arm", () => { + test("Should call the resource's own list with the page it asked for", async () => { + const { client, calls } = fakeProvisioningClient({ + pages: [[{ id: "role-1", name: "Admin" }]], + }) + + const result = await listFetcher<"roles", "provisioning">({ + resourceType: "roles", + client, + clientType: "provisioningSdkClient", + query: { filters: { name_i_cont: "adm" }, pageSize: 25 }, + }) + + expect(calls).toEqual([ + { filters: { name_i_cont: "adm" }, pageSize: 25, pageNumber: 1 }, + ]) + expect(result.list).toEqual([{ id: "role-1", name: "Admin" }]) + }) + + test("Should map the meta the Provisioning API returns", async () => { + const { client } = fakeProvisioningClient({ + pages: [ + [{ id: "role-1", name: "Admin" }], + [{ id: "role-2", name: "Ops" }], + ], + }) + + const result = await listFetcher<"roles", "provisioning">({ + resourceType: "roles", + client, + clientType: "provisioningSdkClient", + }) + + expect(result.meta).toEqual({ + pageCount: 2, + recordCount: 2, + currentPage: 1, + recordsPerPage: 1, + // the Provisioning API has no cursor: only the Metrics API sets one + cursor: null, + }) + }) + + test("Should accumulate pages, as the Core arm does", async () => { + const { client } = fakeProvisioningClient({ + pages: [ + [{ id: "role-1", name: "Admin" }], + [{ id: "role-2", name: "Ops" }], + ], + }) + + const firstPage = await listFetcher<"roles", "provisioning">({ + resourceType: "roles", + client, + clientType: "provisioningSdkClient", + }) + const secondPage = await listFetcher<"roles", "provisioning">({ + resourceType: "roles", + client, + clientType: "provisioningSdkClient", + currentData: firstPage, + }) + + expect(secondPage.list.map((role) => role.id)).toEqual(["role-1", "role-2"]) + }) +}) + +describe("useResourceList, provisioning flavour", () => { + const ProvisioningRoles: FC<{ client: ClientFor<"provisioning"> }> = ({ + client, + }) => { + const { ResourceList } = useResourceList({ + type: "roles", + api: "provisioning", + client, + }) + + return ( + No roles found
} + ItemTemplate={({ resource }) => ( +
{resource?.name ?? "loading"}
+ )} + /> + ) + } + + test("Should render the records the injected client returns", async () => { + const { client } = fakeProvisioningClient({ + pages: [ + [ + { id: "role-1", name: "Admin" }, + { id: "role-2", name: "Ops" }, + ], + ], + }) + + const { findAllByTestId } = render( + // the providers are for the Core client the hook always reads; a + // provisioning list never uses it + + + + + , + ) + + await waitFor(async () => { + const items = await findAllByTestId("roleItem") + expect(items.map((item) => item.textContent)).toEqual(["Admin", "Ops"]) + }) + }) +}) + +describe("useResourceListForApi, without a client", () => { + test("Should fail loudly rather than query the Core API instead", () => { + const Broken: FC = () => { + // the generic entry point cannot enforce the pairing in its types, so a + // missing client used to fall through to the Core client unnoticed + useResourceListForApi({ type: "roles", api: "provisioning" }) + return null + } + + expect(() => render()).toThrow(/needs a client/) + }) +}) + +describe("the api flavour, at the type level", () => { + test("Should require a client for the Provisioning API, and none for Core", () => { + // Each call is kept on one line so its expected error has one place to land. + // These never run: what is being tested is that they compile, or do not. + const assertions = [ + // a Core list is unchanged: no `api`, no `client` + () => useResourceList({ type: "orders" }), + // @ts-expect-error a provisioning list cannot be built without a client + () => useResourceList({ api: "provisioning", type: "roles" }), + // @ts-expect-error `orders` is a Core resource, not a Provisioning one + () => useResourceList({ api: "provisioning", type: "orders", client }), + // and the other way round: a Core list cannot take a provisioning type + // @ts-expect-error `roles` is a Provisioning resource, not a Core one + () => useResourceList({ type: "roles" }), + ] + + expect(assertions).toHaveLength(4) + }) +}) + +declare const client: ClientFor<"provisioning"> diff --git a/packages/app-elements/src/ui/resources/useResourceList/reducer.ts b/packages/app-elements/src/ui/resources/useResourceList/reducer.ts index 56218b1b6..dd78c6b5b 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/reducer.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/reducer.ts @@ -1,19 +1,29 @@ -import type { ListableResourceType } from "@commercelayer/sdk" -import type { FetcherResponse, Resource } from "./listFetcher" +import type { FetcherResponse } from "./listFetcher" -interface ResourceListInternalState { +/** + * Parameterised by the record shape, not by a resource type: nothing here depends + * on which API the list came from, only on the records having an `id`. + */ +export interface ResourceListInternalState { isLoading: boolean error?: { message: string } - data?: FetcherResponse> | undefined + data?: FetcherResponse | undefined } -export const initialState: ResourceListInternalState = { +export const initialState: ResourceListInternalState<{ id: string }> = { isLoading: true, } -type Action = +/** The same initial state, typed for the records this list will hold. */ +export function createInitialState< + TItem extends { id: string }, +>(): ResourceListInternalState { + return { isLoading: true } +} + +export type Action = | { type: "prepare" } @@ -22,7 +32,7 @@ type Action = } | { type: "loaded" - payload: FetcherResponse> + payload: FetcherResponse } | { type: "error" @@ -35,10 +45,10 @@ type Action = } } -export const reducer = ( - state: ResourceListInternalState, - action: Action, -): ResourceListInternalState => { +export const reducer = ( + state: ResourceListInternalState, + action: Action, +): ResourceListInternalState => { switch (action.type) { case "prepare": return { diff --git a/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.integration.test.tsx b/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.integration.test.tsx index 6869121fa..5abd39071 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.integration.test.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.integration.test.tsx @@ -121,6 +121,40 @@ describe("resource list signals, against a mounted list", () => { expect(await findByText("Order #1001")).toBeInTheDocument() }) + it("refetches the first page, so a record created elsewhere shows up", async () => { + // an infinite list works out the page to ask for from the data it holds, so a + // refresh that kept that data would fetch the page *after* the last one and + // append it — leaving a record created at the top of the list invisible + const requestedPages: number[] = [] + const orders = [...mockedOrders] + server.use( + http.get(`https://*/api/orders`, ({ request }) => { + const url = new URL(request.url) + requestedPages.push(Number(url.searchParams.get("page[number]") ?? 1)) + return HttpResponse.json({ + data: orders.map((order) => ({ + id: order.id, + type: "orders", + attributes: { number: order.number }, + })), + meta: { record_count: orders.length, page_count: 1 }, + }) + }), + ) + + const { findByText } = renderList() + expect(await findByText("Order #1001")).toBeInTheDocument() + expect(requestedPages).toEqual([1]) + + orders.unshift({ id: "order-3", number: 1003 }) + act(() => { + refreshResourceLists("orders") + }) + + expect(await findByText("Order #1003")).toBeInTheDocument() + expect(requestedPages).toEqual([1, 1]) + }) + it("stops listening once unmounted", async () => { mockOrdersList() const { findByText, unmount } = renderList() diff --git a/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.ts b/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.ts index b85155836..0b9b4985e 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/resourceListSignals.ts @@ -1,4 +1,11 @@ -import type { ListableResourceType } from "@commercelayer/sdk" +import type { AnyListableResourceType } from "./apiFlavour" + +/** + * The resource type a signal is addressed to, from either API flavour. One flat + * keyspace is safe because the two flavours' listable types are disjoint — + * `organizations` exists in Core but is excluded from its listable union. + */ +type ResourceListKey = AnyListableResourceType /** * What a mounted list is being asked to do. @@ -11,14 +18,14 @@ type ResourceListSignal = type ResourceListSubscriber = (signal: ResourceListSignal) => void -const subscribers = new Map>() +const subscribers = new Map>() /** * Subscribe a mounted list to the signals for a resource type. * Called by `useResourceList`; returns the unsubscribe function. */ export function subscribeToResourceLists( - type: ListableResourceType, + type: ResourceListKey, subscriber: ResourceListSubscriber, ): () => void { const forType = subscribers.get(type) ?? new Set() @@ -33,7 +40,7 @@ export function subscribeToResourceLists( } } -function emit(type: ListableResourceType, signal: ResourceListSignal): void { +function emit(type: ResourceListKey, signal: ResourceListSignal): void { // copied before iterating: a subscriber could unsubscribe while we notify const forType = subscribers.get(type) if (forType == null) { @@ -59,7 +66,7 @@ function emit(type: ListableResourceType, signal: ResourceListSignal): void { * removeFromResourceLists("stock_items", stockItem.id) */ export function removeFromResourceLists( - type: ListableResourceType, + type: ResourceListKey, resourceId: string, ): void { emit(type, { kind: "removeItem", resourceId }) @@ -76,6 +83,6 @@ export function removeFromResourceLists( * A signal emitted while no list is mounted is a no-op — which is harmless, * since a list fetches on mount anyway. */ -export function refreshResourceLists(type: ListableResourceType): void { +export function refreshResourceLists(type: ResourceListKey): void { emit(type, { kind: "refresh" }) } diff --git a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx index 4018160f8..0d015c8c8 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx @@ -19,7 +19,7 @@ const mockedOrder: Order = { } const ResourceListImplementation: FC< - Pick, "query"> + Pick, "query"> > = ({ query }) => { const { ResourceList } = useResourceList({ type: "orders", diff --git a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx index 7b90c6fd0..8d500ad8d 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx @@ -1,8 +1,6 @@ import { CommerceLayerStatic, type ListableResourceType, - type QueryParamsList, - type ResourceFields, } from "@commercelayer/sdk" import React, { type FC, @@ -32,22 +30,36 @@ import type { ThProps } from "#ui/atoms/Table/Th" import { Text } from "#ui/atoms/Text" import { VisibilityTrigger } from "#ui/atoms/VisibilityTrigger" import { InputFeedback } from "#ui/forms/InputFeedback" -import { type FetcherResponse, listFetcher, type Resource } from "./listFetcher" +import type { + AnyListableResourceType, + ApiFlavour, + ClientFor, + ListableResourceTypeFor, + QueryParamsListFor, + ResourceFor, +} from "./apiFlavour" +import { type FetcherResponse, listFetcher } from "./listFetcher" import { useMetricsSdkProvider } from "./metricsApiClient" import { PaginationInfo } from "./PaginationInfo" -import { initialState, reducer } from "./reducer" +import { + type Action, + createInitialState, + type ResourceListInternalState, + reducer, +} from "./reducer" import { subscribeToResourceLists } from "./resourceListSignals" import { useMetricsCursorTrail } from "./useMetricsCursorTrail" import { usePageInUrl } from "./usePageInUrl" import { computeTitleWithTotalCount } from "./utils" export interface ResourceListItemTemplateProps< - TResource extends ListableResourceType, + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", > extends SkeletonTemplateProps<{ /** * The fetched resource */ - resource?: Resource + resource?: ResourceFor /** * callback to be used to remove the item from the list as UI element. * This needs to be used after a successful API call to delete the resource, since it just affects the current UI rendering and not the server data. @@ -59,16 +71,16 @@ type TableVariantHeading = Omit & { label: React.ReactNode } -export type ResourceListProps = Pick< - SectionProps, - "actionButton" -> & { +export type ResourceListProps< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> = Pick & { /** * A react component to be used to render each item in the list. * For best results, pass as `Item` a component already wrapped in a `SkeletonTemplate` (or `withSkeletonTemplate` HOC). * In this way the loading state will be handled automatically. */ - ItemTemplate: FC> + ItemTemplate: FC> /** * An element to be rendered when the list is empty. * When not provided, a default message will be shown. @@ -98,15 +110,23 @@ export type ResourceListProps = Pick< } ) -export type UseResourceListConfig = { +export type UseResourceListConfig< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> = { /** * The resource type to be fetched in the list */ type: TResource + /** + * Which Commerce Layer API the list speaks to. + * @default "core" + */ + api?: TApi /** * SDK query object to be used to fetch the list, excluding the pageNumber that is handled internally for infinite scrolling. */ - query?: Omit, "pageNumber"> + query?: QueryParamsListFor /** * When set the component will fetch data from the Metrics API, and automatically use the returned cursor for infinite scrolling. */ @@ -125,7 +145,9 @@ export type UseResourceListConfig = { * Useful for client-side filtering or sorting that cannot be expressed via the API query. * Affects both the `list` value returned by the hook and what is rendered by ``. */ - preProcess?: (list: Array>) => Array> + preProcess?: ( + list: Array>, + ) => Array> /** * Pagination type: 'infinite' for infinite scrolling (default), 'pagination' for classic prev/next pagination. * Works with both the Core API and the Metrics API. Since the Metrics API is @@ -142,16 +164,30 @@ export type UseResourceListConfig = { * @default 'top' */ paginationScrollTo?: "top" | "list" | "none" + /** + * The client for `api`. Required for the Provisioning API — app-elements has no + * provisioning token of its own, so the caller builds it (see + * `docs/adr/0001-provisioning-api-in-resource-list.md`); the overloads below are + * what enforce that. Never passed for the Core API, whose client comes from + * `CoreSdkProvider` — which is why this is the Provisioning client rather than + * `ClientFor`: a property whose type is conditional on the flavour cannot be + * `Omit`ed or unioned by callers, and consumers that spread the config into a + * component stopped typechecking. + */ + client?: ClientFor<"provisioning"> } // Base return type without Pagination -interface UseResourceListReturn { +export interface UseResourceListReturn< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> { /** The component that renders the list with infinite scrolling or pagination functionality */ - ResourceList: FC> + ResourceList: FC> /** The array of resources to display. When `preProcess` is provided, this is the processed result; otherwise it is the raw fetched data, which grows each time a new page is fetched (infinite mode) or shows current page only (pagination mode) */ - list?: Array> + list?: Array> /** Metadata related to pagination, as returned by the SDK */ - meta?: FetcherResponse>["meta"] + meta?: FetcherResponse>["meta"] /** Indicates whether the list is currently loading the next page */ isLoading: boolean /** Indicates whether the list is loading for the first time (initial page load) */ @@ -173,53 +209,72 @@ interface UseResourceListReturn { // Return type with Pagination component export interface UseResourceListReturnWithPagination< - TResource extends ListableResourceType, -> extends UseResourceListReturn { + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> extends UseResourceListReturn { /** Pagination controls component (only shown when paginationType is 'pagination' and there are multiple pages) */ Pagination: FC } /** * Renders a list of resources of a given type with infinite scrolling or classic pagination. + * @see `docs/adr/0001-provisioning-api-in-resource-list.md` * It's possible to specify a query to filter the list and either * a React component (`ItemTemplate`) to be used as item template for the list or a function as `children` to render a custom element. */ -// Overload: when paginationType is explicitly 'pagination' -export function useResourceList( - config: UseResourceListConfig & { - paginationType: "pagination" - }, -): UseResourceListReturnWithPagination - -// Overload: when paginationType is explicitly 'infinite' or omitted -export function useResourceList( - config: UseResourceListConfig & { - paginationType?: "infinite" - }, -): UseResourceListReturn - -// Fallback overload: when paginationType is a union type or otherwise not narrowable to a literal -export function useResourceList( - config: UseResourceListConfig, -): UseResourceListReturn +/** + * The Provisioning client, or a loud failure. + * + * The public overloads make it impossible to ask for a Provisioning list without + * one, but `useResourceListForApi` is generic over the flavour and cannot: without + * this, a missing client fell through to the Core client and queried the wrong API + * with no sign of it. + */ +function requireProvisioningClient( + client: ClientFor<"provisioning"> | undefined, +): ClientFor<"provisioning"> { + if (client == null) { + throw new Error( + 'A list with api: "provisioning" needs a client: app-elements cannot build one (see docs/adr/0001-provisioning-api-in-resource-list.md).', + ) + } + return client +} -// Implementation signature -export function useResourceList({ +/** + * The list, generic over the API flavour. + * + * `useResourceList` below is the same thing behind overloads that pin the flavour, + * so that a Provisioning list cannot be asked for without a client. Overloads take + * a single type argument, though, so code that is itself generic over the flavour — + * `useResourceTable` — calls this instead. + */ +export function useResourceListForApi< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +>({ type, + api, + client, query, metricsQuery, paginationType = "infinite", paginationScrollTo = "top", preProcess, -}: UseResourceListConfig): - | UseResourceListReturn - | UseResourceListReturnWithPagination { +}: UseResourceListConfig): + | UseResourceListReturn + | UseResourceListReturnWithPagination { + // throws when a Provisioning list was built without a client (see below): at + // mount, so the mistake surfaces where the list is rendered + const provisioningClient = + api === "provisioning" ? requireProvisioningClient(client) : undefined + const { sdkClient } = useCoreSdkProvider() const { metricsClient } = useMetricsSdkProvider() - const [{ data, isLoading, error }, dispatch] = useReducer( - reducer, - initialState, - ) + const [{ data, isLoading, error }, dispatch] = useReducer< + ResourceListInternalState>, + [Action>] + >(reducer, createInitialState>()) const { requestedPage, pushPage, replacePage } = usePageInUrl() const listRef = React.useRef(null) /** @@ -287,15 +342,23 @@ export function useResourceList({ async ({ query, pageNumber, + fromScratch = false, }: { - query?: Omit, "pageNumber"> + query?: QueryParamsListFor pageNumber?: number + /** + * Start the list over from its first page, discarding what is already + * fetched. An infinite list derives the page to ask for from the data it + * holds, so without this a `refresh` would fetch the page *after* the last + * one it has and append it. + */ + fromScratch?: boolean }): Promise => { dispatch({ type: "prepare" }) try { - const listResponse = await listFetcher({ + const listResponse = await listFetcher({ // when is new query, we don't want to pass existing data - currentData: isQueryChanged ? undefined : data, + currentData: isQueryChanged || fromScratch ? undefined : data, resourceType: type, mode: paginationType, pageNumber, @@ -306,17 +369,27 @@ export function useResourceList({ pageNumber != null ? (metricsTrail.cursorFor(pageNumber) ?? null) : undefined, - ...(metricsQuery != null + // Which client answers: the Provisioning one the caller handed over, the + // Metrics one when a metrics query is set, or the Core client from the + // provider. `api` and `metricsQuery` are separate axes — metrics is a + // transport for Core resources, not a third namespace. + ...(api === "provisioning" && provisioningClient != null ? { - clientType: "metricsClient", - client: metricsClient, - query: metricsQuery, - } - : { - clientType: "coreSdkClient", - client: sdkClient, + clientType: "provisioningSdkClient" as const, + client: provisioningClient, query, - }), + } + : metricsQuery != null + ? { + clientType: "metricsClient" as const, + client: metricsClient, + query: metricsQuery, + } + : { + clientType: "coreSdkClient" as const, + client: sdkClient, + query, + }), }) // remember the cursor that will open the *next* page, so it can be // revisited (forwards or backwards) without refetching from page 1 @@ -440,6 +513,9 @@ export function useResourceList({ void fetchMore({ query, pageNumber: paginationType === "pagination" ? 1 : undefined, + // `reset` has not been applied yet, so the fetch would still see the data + // this refresh is throwing away + fromScratch: true, }) }, [query, paginationType, fetchMore, replacePage]) @@ -472,7 +548,7 @@ export function useResourceList({ [pushPage], ) - const ResourceList = useCallback>>( + const ResourceList = useCallback>>( ({ ItemTemplate, emptyState: emptyStateProp, @@ -512,7 +588,8 @@ export function useResourceList({ No{" "} {formatResourceName({ - resource: type, + // only used to build the "No found" label + resource: type as ListableResourceType, count: "plural", })} . @@ -650,7 +727,7 @@ export function useResourceList({ isPreProcessed, ]) - const baseReturn: UseResourceListReturn = { + const baseReturn: UseResourceListReturn = { ResourceList, list: displayList, meta: data?.meta, @@ -675,7 +752,7 @@ export function useResourceList({ return { ...baseReturn, Pagination, - } as UseResourceListReturnWithPagination + } as UseResourceListReturnWithPagination } return baseReturn @@ -763,3 +840,51 @@ const Wrapper: FC<{ ) } + +// Overload: the Provisioning API, whose client the caller owns +export function useResourceList< + TResource extends ListableResourceTypeFor<"provisioning">, +>( + config: UseResourceListConfig & { + api: "provisioning" + client: ClientFor<"provisioning"> + /** Metrics is a transport for Core resources: it has no Provisioning side. */ + metricsQuery?: never + }, +): UseResourceListReturn + +// Overload: when paginationType is explicitly 'pagination' +export function useResourceList< + TResource extends ListableResourceTypeFor<"core">, +>( + config: UseResourceListConfig & { + paginationType: "pagination" + }, +): UseResourceListReturnWithPagination + +// Overload: when paginationType is explicitly 'infinite' or omitted +export function useResourceList< + TResource extends ListableResourceTypeFor<"core">, +>( + config: UseResourceListConfig & { + paginationType?: "infinite" + }, +): UseResourceListReturn + +// Fallback overload: when paginationType is a union type or otherwise not narrowable to a literal +export function useResourceList< + TResource extends ListableResourceTypeFor<"core">, +>( + config: UseResourceListConfig, +): UseResourceListReturn + +// Implementation for the overloads above. The overloads are what callers see; this +// signature only has to cover all of them, which the widest instantiation does — +// every resource type either API can list, at either flavour. +export function useResourceList( + config: UseResourceListConfig, +): + | UseResourceListReturn + | UseResourceListReturnWithPagination { + return useResourceListForApi(config) +} diff --git a/packages/app-elements/src/ui/resources/useResourceTable/types.ts b/packages/app-elements/src/ui/resources/useResourceTable/types.ts index 29c6ebdde..acccd3792 100644 --- a/packages/app-elements/src/ui/resources/useResourceTable/types.ts +++ b/packages/app-elements/src/ui/resources/useResourceTable/types.ts @@ -1,10 +1,11 @@ -import type { - ListableResourceType, - ResourceSortFields, -} from "@commercelayer/sdk" import type { FC, ReactNode } from "react" import type { SectionProps } from "#ui/atoms/Section" -import type { Resource } from "../useResourceList/listFetcher" +import type { + ApiFlavour, + ListableResourceTypeFor, + ResourceFor, + SortableAttributeFor, +} from "../useResourceList/apiFlavour" import type { UseResourceListConfig } from "../useResourceList/useResourceList" /** @@ -14,7 +15,10 @@ import type { UseResourceListConfig } from "../useResourceList/useResourceList" * detail and its `ColumnDef` is intentionally not exposed here (see * `docs/adr/0001-encapsulate-tanstack-table.md`). */ -export interface ResourceTableColumn { +export interface ResourceTableColumn< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> { /** * Header content. A plain string or any node (icon, tooltip, …). */ @@ -23,7 +27,7 @@ export interface ResourceTableColumn { * Cell renderer for this column. Receives the fetched resource for the row * and returns whatever should be displayed in the cell. */ - cell: (props: { resource: Resource }) => ReactNode + cell: (props: { resource: ResourceFor }) => ReactNode /** * Stable, unique column id. * When omitted it falls back to `sortBy`, then to a positional `col-`. @@ -92,7 +96,7 @@ export interface ResourceTableColumn { * It marks the column as sortable and names the attribute, which is what a sort * control outside the table reads to build its options. */ - sortBy?: SortableAttribute | MetricsAttribute + sortBy?: SortableAttribute | MetricsAttribute } /** @@ -123,10 +127,10 @@ export type ResourceTableColumnKind = * API rejects anything else, and computed values (a status derived from several * timestamps, a relationship's name) are not in it by definition. */ -export type SortableAttribute = Extract< - keyof ResourceSortFields[TResource], - string -> +export type SortableAttribute< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> = SortableAttributeFor /** * A Metrics API sort attribute, always namespaced by its entity @@ -141,89 +145,95 @@ export type MetricsAttribute = `${string}.${string}` * `undefined` means no explicit table sort is applied. */ export type ResourceTableSort< - TResource extends ListableResourceType = ListableResourceType, + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", > = - | SortableAttribute - | `-${SortableAttribute}` + | SortableAttribute + | `-${SortableAttribute}` | MetricsAttribute | `-${MetricsAttribute}` | undefined -export type UseResourceTableConfig = - Omit, "metricsQuery" | "query"> & { - /** The columns to render, in display order. */ - columns: Array> - /** - * SDK query object, excluding `pageNumber` (handled internally) and - * `sort` (owned by the table's sorting state — set the initial sort with - * `sort` instead). - */ - query?: Omit["query"]>, "sort"> - /** - * When set, data is fetched from the Metrics API instead of the Core API. - * - * Sorting still works: a column's `sortBy` is sent as the metrics - * `search.sort_by` (so use metrics attribute names, e.g. `"order.placed_at"`) - * together with the matching `search.sort` direction — omit `search.sort_by` - * here and let the table own it. - */ - metricsQuery?: { - search: { - limit?: number - fields?: string[] - } - /** - * Metrics filters. When the table is rendered through - * `useResourceFilters`' `FilteredTable`, this is injected from the active - * filters and must not be set here. - */ - filter?: Record +export type UseResourceTableConfig< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +> = Omit, "metricsQuery" | "query"> & { + /** The columns to render, in display order. */ + columns: Array> + /** + * SDK query object, excluding `pageNumber` (handled internally) and + * `sort` (owned by the table's sorting state — set the initial sort with + * `sort` instead). + */ + query?: Omit< + NonNullable["query"]>, + "sort" + > + /** + * When set, data is fetched from the Metrics API instead of the Core API. + * + * Sorting still works: a column's `sortBy` is sent as the metrics + * `search.sort_by` (so use metrics attribute names, e.g. `"order.placed_at"`) + * together with the matching `search.sort` direction — omit `search.sort_by` + * here and let the table own it. + */ + metricsQuery?: { + search: { + limit?: number + fields?: string[] } /** - * Optional row-level click handler. When provided the whole row becomes - * interactive (hover affordance + click). Use it to navigate with your - * app's router. - * - * The click event is passed as second argument, so it can be forwarded to - * helpers that need it (e.g. `navigateTo(...).onClick`). - */ - onRowClick?: ( - resource: Resource, - event: React.MouseEvent, - ) => void - /** - * Return an href to make each row a real link (rendered as a stretched - * anchor over the row). This enables native link behavior — cmd/ctrl/middle - * click opens the row in a new tab, and the URL shows on hover. - * - * Combine with `onRowClick` for client-side navigation: a plain click calls - * `onRowClick` (and suppresses the default navigation), while modified - * clicks fall through to the browser. Return `undefined` to leave a row - * non-navigable. - * - * Note: avoid interactive elements in the first column when using this — the - * stretched anchor sits over the row (in-cell controls would need their own - * `relative`/`z-10` to stay clickable). - */ - getRowHref?: (resource: Resource) => string | undefined - /** - * Controlled sort value (SDK sort expression, e.g. `"-created_at"`). - * Pass together with `onSortChange` to own the sort state (e.g. persist it - * in the URL). When omitted the table manages sort internally. - */ - sort?: ResourceTableSort - /** - * Called when the sort changes. Provide together with `sort` for controlled - * mode; the callback receives the new SDK sort expression (or `undefined` when - * sorting is cleared). - */ - onSortChange?: (sort: ResourceTableSort) => void - /** - * Initial sort used only when the table manages sort internally - * (uncontrolled). Ignored when `sort`/`onSortChange` are provided. + * Metrics filters. When the table is rendered through + * `useResourceFilters`' `FilteredTable`, this is injected from the active + * filters and must not be set here. */ - defaultSort?: ResourceTableSort + filter?: Record } + /** + * Optional row-level click handler. When provided the whole row becomes + * interactive (hover affordance + click). Use it to navigate with your + * app's router. + * + * The click event is passed as second argument, so it can be forwarded to + * helpers that need it (e.g. `navigateTo(...).onClick`). + */ + onRowClick?: ( + resource: ResourceFor, + event: React.MouseEvent, + ) => void + /** + * Return an href to make each row a real link (rendered as a stretched + * anchor over the row). This enables native link behavior — cmd/ctrl/middle + * click opens the row in a new tab, and the URL shows on hover. + * + * Combine with `onRowClick` for client-side navigation: a plain click calls + * `onRowClick` (and suppresses the default navigation), while modified + * clicks fall through to the browser. Return `undefined` to leave a row + * non-navigable. + * + * Note: avoid interactive elements in the first column when using this — the + * stretched anchor sits over the row (in-cell controls would need their own + * `relative`/`z-10` to stay clickable). + */ + getRowHref?: (resource: ResourceFor) => string | undefined + /** + * Controlled sort value (SDK sort expression, e.g. `"-created_at"`). + * Pass together with `onSortChange` to own the sort state (e.g. persist it + * in the URL). When omitted the table manages sort internally. + */ + sort?: ResourceTableSort + /** + * Called when the sort changes. Provide together with `sort` for controlled + * mode; the callback receives the new SDK sort expression (or `undefined` when + * sorting is cleared). + */ + onSortChange?: (sort: ResourceTableSort) => void + /** + * Initial sort used only when the table manages sort internally + * (uncontrolled). Ignored when `sort`/`onSortChange` are provided. + */ + defaultSort?: ResourceTableSort +} /** Props of the `ResourceTable` component returned by the hook. */ export interface ResourceTableProps { @@ -253,17 +263,18 @@ export interface ResourceTableProps { } export interface UseResourceTableReturn< - TResource extends ListableResourceType, + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", > { /** The component that renders the data table. */ ResourceTable: FC /** Prev/next pagination controls. Renders `null` unless in `pagination` mode with more than one page. */ Pagination: FC /** The rows currently displayed (current page, or accumulated in infinite mode). */ - list?: Array> + list?: Array> /** SDK pagination metadata. */ meta?: import("../useResourceList/listFetcher").FetcherResponse< - Resource + ResourceFor >["meta"] isLoading: boolean isFirstLoading: boolean @@ -274,7 +285,7 @@ export interface UseResourceTableReturn< refresh: () => void hasMorePages?: boolean /** The active sort (SDK sort expression), whether controlled or internal. */ - sort: ResourceTableSort + sort: ResourceTableSort /** * Sets the sort, for a control outside the table to drive — a field + direction * picker, say. Table headers are inert (see `sortBy`). @@ -282,5 +293,5 @@ export interface UseResourceTableReturn< * In controlled mode (`sort` + `onSortChange`) this calls `onSortChange` rather * than holding state of its own. */ - setSort: (sort: ResourceTableSort) => void + setSort: (sort: ResourceTableSort) => void } diff --git a/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx b/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx index 7258ea8d3..4fb2dcf8c 100644 --- a/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx +++ b/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx @@ -16,11 +16,16 @@ import { Spacer } from "#ui/atoms/Spacer" import { Table, Td, Th, Tr } from "#ui/atoms/Table" import { Text } from "#ui/atoms/Text" import { VisibilityTrigger } from "#ui/atoms/VisibilityTrigger" -import type { Resource } from "../useResourceList/listFetcher" +import type { + ApiFlavour, + ClientFor, + ListableResourceTypeFor, + ResourceFor, +} from "../useResourceList/apiFlavour" import { type UseResourceListConfig, type UseResourceListReturnWithPagination, - useResourceList, + useResourceListForApi, } from "../useResourceList/useResourceList" import { computeTitleWithTotalCount } from "../useResourceList/utils" import type { @@ -47,10 +52,10 @@ const EMPTY_DATA: unknown[] = [] type TableRow = { id: string } /** Resolve a stable column id: explicit `id`, then `sortBy`, then positional. */ -function getColumnId( - column: ResourceTableColumn, - index: number, -): string { +function getColumnId< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +>(column: ResourceTableColumn, index: number): string { return column.id ?? column.sortBy ?? `col-${index}` } @@ -347,9 +352,40 @@ function visibilityClassName( * replaces item rendering with a TanStack-driven table. Sorting, filtering, * search and pagination are all resolved server-side. */ -export function useResourceTable( - config: UseResourceTableConfig, -): UseResourceTableReturn { +// Overload: the Provisioning API, whose client the caller owns +export function useResourceTable< + TResource extends ListableResourceTypeFor<"provisioning">, +>( + config: UseResourceTableConfig & { + api: "provisioning" + client: ClientFor<"provisioning"> + /** Metrics is a transport for Core resources: it has no Provisioning side. */ + metricsQuery?: never + }, +): UseResourceTableReturn + +// Overload: the Core API, whose client comes from `CoreSdkProvider` +export function useResourceTable< + TResource extends ListableResourceTypeFor<"core">, +>( + config: UseResourceTableConfig, +): UseResourceTableReturn + +// Implementation signature +export function useResourceTable< + TResource extends ListableResourceTypeFor, + TApi extends ApiFlavour = "core", +>( + config: UseResourceTableConfig, +): UseResourceTableReturn { + // The flavour and, for provisioning, the caller's client. Read through a plain + // view of the config: their declared types are conditional on `TApi`, which + // cannot be destructured while the flavour is still a type parameter. + const { api, client } = config as { + api?: ApiFlavour + client?: ClientFor<"provisioning"> + } + const { type, columns, @@ -370,11 +406,11 @@ export function useResourceTable( // Sort state: controlled when `onSortChange` is provided, otherwise internal. const isControlled = onSortChange != null const [internalSort, setInternalSort] = useState< - ResourceTableSort + ResourceTableSort >(() => defaultSort) const sort = isControlled ? controlledSort : internalSort const setSort = useCallback( - (next: ResourceTableSort) => { + (next: ResourceTableSort) => { if (isControlled) { onSortChange?.(next) } else { @@ -397,7 +433,7 @@ export function useResourceTable( ({ ...query, ...(!isMetrics && sort != null && sort !== "" ? { sort: [sort] } : {}), - }) as NonNullable["query"]>, + }) as NonNullable["query"]>, [query, sort, isMetrics], ) @@ -418,17 +454,25 @@ export function useResourceTable( } : {}), }, - } as NonNullable["metricsQuery"]> + } as NonNullable< + UseResourceListConfig["metricsQuery"] + > }, [metricsQuery, sort]) - const result = useResourceList({ + // The cast is the one place the flavour has to be taken on trust: everything + // here came out of a `UseResourceTableConfig` that already satisfied the pairing + // rules (a provisioning table cannot be built without a client), but TypeScript + // cannot re-check a conditional type while `TApi` is still a parameter. + const result = useResourceListForApi({ type, + api, + client, query: mergedQuery, metricsQuery: mergedMetricsQuery, preProcess, paginationType, paginationScrollTo, - }) + } as UseResourceListConfig) const { list, @@ -444,12 +488,13 @@ export function useResourceTable( const Pagination = paginationType === "pagination" - ? (result as UseResourceListReturnWithPagination).Pagination + ? (result as UseResourceListReturnWithPagination) + .Pagination : NullComponent const tableColumns = useMemo(() => { // Type the column helper/table with a minimal row shape rather than the full - // `Resource` SDK union: pushing that large conditional type + // `ResourceFor` SDK union: pushing that large conditional type // through TanStack's generics while `TResource` is unresolved triggers // "excessively deep" (TS2589). The real row type is preserved by the // `ResourceTableColumn` public API and the cast in `cell`. @@ -460,7 +505,9 @@ export function useResourceTable( id: getColumnId(column, index), header: () => column.header, cell: ({ row }) => - column.cell({ resource: row.original as Resource }), + column.cell({ + resource: row.original as ResourceFor, + }), }), ), ) @@ -570,7 +617,13 @@ export function useResourceTable( const defaultEmptyState = ( - No {formatResourceName({ resource: type, count: "plural" })}. + No{" "} + {formatResourceName({ + // only used to build the "No ." label + resource: type as ListableResourceType, + count: "plural", + })} + . ) @@ -638,7 +691,7 @@ export function useResourceTable( {isFirstLoading ? renderSkeletonRows(8, "skeleton") : table.getRowModel().rows.map((row) => { - const resource = row.original as Resource + const resource = row.original as ResourceFor const href = getRowHref?.(resource) const clickable = href != null || onRowClick != null return ( diff --git a/packages/docs/src/stories/examples/ListProvisioningResources.stories.tsx b/packages/docs/src/stories/examples/ListProvisioningResources.stories.tsx new file mode 100644 index 000000000..490b911e6 --- /dev/null +++ b/packages/docs/src/stories/examples/ListProvisioningResources.stories.tsx @@ -0,0 +1,260 @@ +import type { Meta, StoryFn } from "@storybook/react-vite" +import { formatDate } from "#helpers/date" +import { CoreSdkProvider } from "#providers/CoreSdkProvider" +import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" +import { Icon } from "#ui/atoms/Icon" +import { Text } from "#ui/atoms/Text" +import { ListItem } from "#ui/composite/ListItem" +import { useResourceList } from "#ui/resources/useResourceList" +import type { ClientFor } from "#ui/resources/useResourceList/apiFlavour" +import { + type ResourceTableColumn, + useResourceTable, +} from "#ui/resources/useResourceTable" + +const setup: Meta = { + title: "Examples/List Provisioning Resources", + parameters: { + layout: "padded", + docs: { + source: { + type: "code", + }, + }, + }, + decorators: [ + (Story) => ( + // The providers are for the Core client the hooks always read. A + // provisioning list never uses it — it fetches with the client it is given. + + + + + + ), + ], +} +export default setup + +/* ------------------------------------------------------------------------- * + * A stand-in for the Provisioning client. + * + * The real one is `CommerceLayerProvisioning({ accessToken })`, built by the app + * from its own token. These stories build a small in-memory one instead, which + * is the same thing the hooks see: a client is a set of resources, each with a + * `list` that returns an array carrying its own `meta`. + * ------------------------------------------------------------------------- */ + +interface FakeRecord { + id: string + name: string + kind?: string + created_at: string +} + +const roles: FakeRecord[] = [ + { id: "role-1", name: "Admin", created_at: "2023-01-12T09:20:00.000Z" }, + { id: "role-2", name: "Read only", created_at: "2023-03-04T14:05:00.000Z" }, + { + id: "role-3", + name: "Order manager", + created_at: "2024-02-19T11:45:00.000Z", + }, + { id: "role-4", name: "Warehouse", created_at: "2024-07-01T08:30:00.000Z" }, + { id: "role-5", name: "Support", created_at: "2025-01-23T16:10:00.000Z" }, +] + +const apiCredentials: FakeRecord[] = [ + { + id: "cred-1", + name: "Storefront", + kind: "sales_channel", + created_at: "2023-02-24T10:00:00.000Z", + }, + { + id: "cred-2", + name: "ERP sync", + kind: "integration", + created_at: "2023-10-17T13:20:00.000Z", + }, + { + id: "cred-3", + name: "Checkout", + kind: "sales_channel", + created_at: "2024-01-05T09:15:00.000Z", + }, + { + id: "cred-4", + name: "Admin webapp", + kind: "webapp", + created_at: "2024-06-11T17:40:00.000Z", + }, + { + id: "cred-5", + name: "CLI", + kind: "integration", + created_at: "2025-03-08T12:00:00.000Z", + }, +] + +/** + * One resource of the fake client: honours `pageSize`, `pageNumber` and the + * `sort` the table sets, and answers with the `meta` the Provisioning API + * returns — `pageCount`, `recordCount`, `currentPage`, `recordsPerPage`, and no + * cursor, which only the Metrics API has. + */ +function fakeResource(records: FakeRecord[]): { + list: (params: Record) => Promise +} { + return { + list: async (params) => { + const pageSize = Number(params.pageSize ?? 25) + const pageNumber = Number(params.pageNumber ?? 1) + + const sortExpression = Array.isArray(params.sort) + ? String(params.sort[0] ?? "") + : "" + const descending = sortExpression.startsWith("-") + const attribute = descending ? sortExpression.slice(1) : sortExpression + + const sorted = + attribute === "" + ? records + : [...records].sort((a, b) => { + const left = String(a[attribute as keyof FakeRecord] ?? "") + const right = String(b[attribute as keyof FakeRecord] ?? "") + return descending + ? right.localeCompare(left) + : left.localeCompare(right) + }) + + const page = sorted.slice( + (pageNumber - 1) * pageSize, + pageNumber * pageSize, + ) + + // the shape the SDK returns: an array with `meta` hung off it + return Object.assign(page, { + meta: { + pageCount: Math.ceil(records.length / pageSize), + recordCount: records.length, + currentPage: pageNumber, + recordsPerPage: pageSize, + }, + }) + }, + } +} + +const provisioningClient = { + roles: fakeResource(roles), + api_credentials: fakeResource(apiCredentials), +} as unknown as ClientFor<"provisioning"> + +/** + * `useResourceList` and `useResourceTable` speak to the Provisioning API when + * given `api: "provisioning"` — the resource types, query and returned records + * are then the Provisioning SDK's (`roles`, `memberships`, `api_credentials`, + * `organizations`, …) rather than the Core API's. + * + * **The caller passes the client.** app-elements has no provisioning token of + * its own — `TokenProvider` never sees one — so a provisioning list is handed a + * client and the types make it mandatory. An app typically wraps that injection + * once, so its own call sites stay free of plumbing (see + * `docs/adr/0001-provisioning-api-in-resource-list.md`): + * + * ```tsx + * export function useProvisioningResourceList( + * config: Omit, "api" | "client" | "metricsQuery">, + * ): UseResourceListReturn { + * const { sdkClient } = useProvisioningSdkProvider() + * return useResourceList({ ...config, api: "provisioning", client: sdkClient }) + * } + * ``` + * + * Everything else is the same list you already know: infinite scrolling, the + * loading skeleton, the empty state, the record count in the title, and the + * refresh/remove signals. + */ +export const Default: StoryFn = () => { + const { ResourceList } = useResourceList({ + type: "roles", + api: "provisioning", + client: provisioningClient, + query: { + pageSize: 25, + sort: { created_at: "asc" }, + }, + }) + + return ( + ( + + + {isLoading === true ? "Loading role" : resource?.name} + + + + )} + /> + ) +} + +const columns: Array> = [ + { + header: "Name", + cell: ({ resource }) => {resource.name}, + }, + { + header: "Kind", + kind: "text", + // no `sortBy`: it accepts only what the Provisioning API can sort api + // credentials by — `id`, `mode`, `created_at`, `updated_at`, `reference`, + // `reference_origin` — taken from the SDK, and `kind` is not among them, so + // this column stays static rather than sending a sort the API would reject + cell: ({ resource }) => {resource.kind}, + }, + { + header: "Created", + kind: "datetime", + sortBy: "created_at", + cell: ({ resource }) => ( + + {formatDate({ format: "date", isoDate: resource.created_at })} + + ), + }, +] + +/** + * The table flavour, with the columns typed against the Provisioning resource: + * `resource` in each `cell` is an `ApiCredential`, and `sortBy` accepts only the + * attributes the Provisioning API can sort api credentials by. + * + * Sorting and pagination work exactly as they do on the Core API — both are + * server-side, so they become query parameters the client sends. `defaultSort` + * sets the initial sort; headers are inert, so a control outside the table + * drives it from there through `sort` + `onSortChange` (or `setSort`). + */ +export const AsTable: StoryFn = () => { + const { ResourceTable, Pagination } = useResourceTable({ + type: "api_credentials", + api: "provisioning", + client: provisioningClient, + columns, + query: { + pageSize: 3, + }, + defaultSort: "-created_at", + }) + + return ( + <> + + + + ) +} diff --git a/packages/docs/src/stories/resources/useResourceTable.stories.tsx b/packages/docs/src/stories/resources/useResourceTable.stories.tsx index 1dcbd7b61..34820483c 100644 --- a/packages/docs/src/stories/resources/useResourceTable.stories.tsx +++ b/packages/docs/src/stories/resources/useResourceTable.stories.tsx @@ -83,8 +83,14 @@ export const Default: StoryFn = () => { } /** - * Declare a `sortBy` on any column to make its header sortable. Sorting is - * server-side: clicking the header drives the SDK `sort` param and refetches. + * Declare a `sortBy` on a column to name the attribute it sorts by — only + * attributes the API can actually sort the resource by are accepted. Sorting is + * server-side: the attribute goes into the SDK `sort` param and the list + * refetches; rows are never reordered client-side. + * + * The headers themselves are inert. `defaultSort` sets the initial sort, and a + * control outside the table drives it from there (`sort` + `onSortChange`, or + * the `setSort` the hook returns). */ export const WithSorting: StoryFn = () => { const { ResourceTable } = useResourceTable({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85b5d7e4..c547e2f1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@commercelayer/js-auth': specifier: ^7.4.2 version: 7.4.2 + '@commercelayer/provisioning-sdk': + specifier: 2.10.2 + version: 2.10.2 '@commercelayer/sdk': specifier: 8.0.0-beta.11 version: 8.0.0-beta.11 @@ -167,7 +170,7 @@ importers: version: 3.2.0(date-fns@4.4.0) jsdom: specifier: ^27.4.0 - version: 27.4.0(supports-color@7.2.0) + version: 27.4.0 msw: specifier: ^2.14.6 version: 2.15.0(@types/node@22.20.1)(typescript@5.9.3) @@ -191,7 +194,7 @@ importers: version: 4.5.4(@types/node@22.20.1)(rollup@4.62.4)(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0)) vitest: specifier: ^3.2.6 - version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@27.4.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(supports-color@7.2.0)(terser@5.50.0)(yaml@2.9.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(supports-color@7.2.0)(terser@5.50.0)(yaml@2.9.0) wouter: specifier: ^3.10.0 version: 3.10.0(react@19.2.4) @@ -258,7 +261,7 @@ importers: version: 19.2.3(@types/react@19.2.13) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(supports-color@7.2.0)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0)) + version: 5.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0)) babel-loader: specifier: ^10.1.1 version: 10.1.1(@babel/core@8.0.1)(webpack@5.109.2(esbuild@0.28.2)(lightningcss@1.32.0)) @@ -1050,6 +1053,10 @@ packages: resolution: {integrity: sha512-ifRcoAL1R7o5AU5w4bgmSnNMEWXDc11A+yZwAAZMEkAYrrsaud1X/3xmgY1e0yIXK8AAdIiqfW/Rm/4DdWwAQA==} engines: {node: '>=20.0.0'} + '@commercelayer/provisioning-sdk@2.10.2': + resolution: {integrity: sha512-5uqqH5CXXAQPuMrz+IGbF2HcQaKzgMzF7z4ye/mdo9Ygp8uMMNYH5jnaPB4QvYBVOMSi8r20egF3B9sFvqILLQ==} + engines: {node: '>=20'} + '@commercelayer/sdk@8.0.0-beta.11': resolution: {integrity: sha512-EhbpwW8GOsty1H3/YiEDzPZDKuvoXW9nMbrnkFQ5+sSgEiSkFXDM/tFxImkwagX7PbHKyHHaEBn5SoAPiNBwtw==} engines: {node: '>=20'} @@ -5983,7 +5990,7 @@ snapshots: '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7(supports-color@7.2.0)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -6003,6 +6010,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@8.0.1': dependencies: '@babel/code-frame': 8.0.0 @@ -6065,7 +6092,7 @@ snapshots: '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 @@ -6129,7 +6156,7 @@ snapshots: '@babel/traverse': 8.0.4 '@babel/types': 8.0.4 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-module-imports': 7.29.7 @@ -6138,6 +6165,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@8.0.1(@babel/core@8.0.1)': dependencies: '@babel/core': 8.0.1 @@ -6168,7 +6204,7 @@ snapshots: '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.8 @@ -6265,17 +6301,17 @@ snapshots: '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-arrow-functions@8.0.1(@babel/core@8.0.1)': @@ -6309,7 +6345,7 @@ snapshots: '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: @@ -6387,7 +6423,7 @@ snapshots: '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) @@ -6431,7 +6467,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: @@ -6469,7 +6505,7 @@ snapshots: '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-nullish-coalescing-operator@8.0.1(@babel/core@8.0.1)': @@ -6503,7 +6539,7 @@ snapshots: '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 transitivePeerDependencies: @@ -6522,7 +6558,7 @@ snapshots: '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: @@ -6546,14 +6582,24 @@ snapshots: '@babel/core': 8.0.1 '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-regenerator@8.0.2(@babel/core@8.0.1)': @@ -6600,7 +6646,7 @@ snapshots: '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 @@ -6703,7 +6749,7 @@ snapshots: '@babel/preset-flow@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) @@ -6719,7 +6765,7 @@ snapshots: '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -6730,7 +6776,7 @@ snapshots: '@babel/register@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 clone-deep: 4.0.1 find-cache-dir: 2.1.0 make-dir: 2.1.0 @@ -6820,6 +6866,8 @@ snapshots: '@commercelayer/js-auth@7.4.2': {} + '@commercelayer/provisioning-sdk@2.10.2': {} + '@commercelayer/sdk@8.0.0-beta.11': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.0)': @@ -7360,8 +7408,8 @@ snapshots: '@npmcli/agent@4.0.2': dependencies: agent-base: 7.1.4 - http-proxy-agent: 7.0.2(supports-color@7.2.0) - https-proxy-agent: 7.0.6(supports-color@7.2.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 lru-cache: 11.5.2 socks-proxy-agent: 8.0.5 transitivePeerDependencies: @@ -8340,6 +8388,18 @@ snapshots: '@vitejs/plugin-react@5.2.0(supports-color@7.2.0)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-rc.3 @@ -8655,7 +8715,7 @@ snapshots: babel-core@7.0.0-bridge.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 babel-loader@10.1.1(@babel/core@8.0.1)(webpack@5.109.2(esbuild@0.28.2)(lightningcss@1.32.0)): dependencies: @@ -9450,7 +9510,7 @@ snapshots: http-cache-semantics@4.2.0: {} - http-proxy-agent@7.0.2(supports-color@7.2.0): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@7.2.0) @@ -9464,7 +9524,7 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@7.2.0): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@7.2.0) @@ -9645,7 +9705,7 @@ snapshots: jscodeshift@0.15.2(@babel/preset-env@8.0.2(@babel/core@8.0.1)): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/parser': 7.29.8 '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) @@ -9670,7 +9730,7 @@ snapshots: transitivePeerDependencies: - supports-color - jsdom@27.4.0(supports-color@7.2.0): + jsdom@27.4.0: dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 @@ -9679,8 +9739,8 @@ snapshots: data-urls: 6.0.1 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2(supports-color@7.2.0) - https-proxy-agent: 7.0.6(supports-color@7.2.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 parse5: 8.0.1 saxes: 6.0.0 @@ -11021,7 +11081,7 @@ snapshots: react-docgen@8.0.3: dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 @@ -11796,7 +11856,7 @@ snapshots: terser: 5.50.0 yaml: 2.9.0 - vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@27.4.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(supports-color@7.2.0)(terser@5.50.0)(yaml@2.9.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(supports-color@7.2.0)(terser@5.50.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -11824,7 +11884,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 22.20.1 - jsdom: 27.4.0(supports-color@7.2.0) + jsdom: 27.4.0 transitivePeerDependencies: - jiti - less