From 6c17b3f6106336a7075dfa2512a7659e190e4784 Mon Sep 17 00:00:00 2001 From: Pier Francesco Ferrari Date: Thu, 1 Oct 2026 17:12:26 +0200 Subject: [PATCH 1/2] feat: improve resource info components --- .../src/hooks/useEditMetadataOverlay.tsx | 61 ---- .../src/hooks/useEditTagsOverlay.tsx | 236 --------------- packages/app-elements/src/main.ts | 12 +- .../app-elements/src/ui/composite/Address.tsx | 1 + .../app-elements/src/ui/composite/Modal.tsx | 31 +- .../src/ui/forms/InputSelect/styles.ts | 6 + .../ResourceAddressFormFields.tsx | 2 +- .../useResourceAddressForm.tsx | 45 ++- .../useResourceAddressModal.tsx | 7 +- .../ResourceMetadata.test.tsx | 37 ++- .../ResourceMetadata/ResourceMetadata.tsx | 127 ++++---- .../ResourceMetadata/ResourceMetadataForm.tsx | 232 --------------- .../ResourceMetadataFormFields.tsx | 196 +++++++++++++ .../ui/resources/ResourceMetadata/index.tsx | 11 + .../useResourceMetadataForm.tsx | 175 +++++++++++ .../useResourceMetadataModal.tsx | 82 ++++++ .../ui/resources/ResourceMetadata/utils.ts | 7 + .../{ => ResourceTags}/ResourceTags.test.tsx | 7 +- .../{ => ResourceTags}/ResourceTags.tsx | 36 +-- .../src/ui/resources/ResourceTags/index.tsx | 12 + .../ResourceTags/useResourceTagsForm.tsx | 271 ++++++++++++++++++ .../ResourceTags/useResourceTagsModal.tsx | 91 ++++++ .../src/stories/composite/Modal.stories.tsx | 22 ++ .../resources/ResourceDetails.stories.tsx | 4 +- .../resources/ResourceMetadata.stories.tsx | 49 ++-- .../resources/ResourceTags.stories.tsx | 47 +-- 26 files changed, 1132 insertions(+), 675 deletions(-) delete mode 100644 packages/app-elements/src/hooks/useEditMetadataOverlay.tsx delete mode 100644 packages/app-elements/src/hooks/useEditTagsOverlay.tsx delete mode 100644 packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataForm.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataFormFields.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataForm.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataModal.tsx rename packages/app-elements/src/ui/resources/{ => ResourceTags}/ResourceTags.test.tsx (82%) rename packages/app-elements/src/ui/resources/{ => ResourceTags}/ResourceTags.tsx (89%) create mode 100644 packages/app-elements/src/ui/resources/ResourceTags/index.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsForm.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsModal.tsx diff --git a/packages/app-elements/src/hooks/useEditMetadataOverlay.tsx b/packages/app-elements/src/hooks/useEditMetadataOverlay.tsx deleted file mode 100644 index c6ec1160d..000000000 --- a/packages/app-elements/src/hooks/useEditMetadataOverlay.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { type FC, useCallback } from "react" -import { useOverlay } from "#hooks/useOverlay" -import { useTranslation } from "#providers/I18NProvider" -import { PageLayout } from "#ui/composite/PageLayout" -import type { ResourceMetadataProps } from "#ui/resources/ResourceMetadata" -import { ResourceMetadataForm } from "#ui/resources/ResourceMetadata/ResourceMetadataForm" - -export interface EditMetadataOverlayProps { - /** - * Optional title shown as first line in edit overlay heading - */ - title?: string - resourceId: ResourceMetadataProps["resourceId"] - resourceType: ResourceMetadataProps["resourceType"] -} - -interface MetadataOverlayHook { - show: () => void - Overlay: FC -} - -export function useEditMetadataOverlay(): MetadataOverlayHook { - const { Overlay: OverlayElement, open, close } = useOverlay() - const { t } = useTranslation() - - const OverlayComponent = useCallback>( - ({ title = "Back", resourceId, resourceType }) => { - return ( - - { - close() - }, - }} - > - { - close() - }} - /> - - - ) - }, - [OverlayElement], - ) - - return { - show: open, - Overlay: OverlayComponent, - } -} diff --git a/packages/app-elements/src/hooks/useEditTagsOverlay.tsx b/packages/app-elements/src/hooks/useEditTagsOverlay.tsx deleted file mode 100644 index 695739d5b..000000000 --- a/packages/app-elements/src/hooks/useEditTagsOverlay.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import type { ListResponse, Tag } from "@commercelayer/sdk" -import isEmpty from "lodash-es/isEmpty" -import { useCallback, useState } from "react" -import { navigateTo } from "#helpers/appsNavigation" -import { useOverlay } from "#hooks/useOverlay" -import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" -import { useTranslation } from "#providers/I18NProvider" -import { useTokenProvider } from "#providers/TokenProvider" -import { Button } from "#ui/atoms/Button" -import { Text } from "#ui/atoms/Text" -import { PageLayout } from "#ui/composite/PageLayout" -import { - InputSelect, - type InputSelectValue, - isMultiValueSelected, -} from "#ui/forms/InputSelect" -import type { ResourceTagsProps } from "#ui/resources/ResourceTags" - -export interface EditTagsOverlayProps { - /** - * Optional title shown as first line in edit overlay heading - */ - title?: string - /** - * Optional setting to define if tags app management link is to be shown in edit overlay heading - */ - showManageAction?: boolean - resourceId: ResourceTagsProps["resourceId"] - resourceType: ResourceTagsProps["resourceType"] -} - -interface TagsOverlayHook { - show: () => void - Overlay: React.FC -} - -export function useEditTagsOverlay(): TagsOverlayHook { - const { - Overlay: OverlayElement, - open, - close, - } = useOverlay({ queryParam: "edit-tags" }) - - const { settings } = useTokenProvider() - const { t } = useTranslation() - - const [selectedTagsLimitReached, setSelectedTagsLimitReached] = - useState(false) - - const navigateToTagsManagement = navigateTo({ - destination: { - app: "tags", - mode: settings.mode, - }, - }) - - const resourceName = t("resources.tags.name_other") - - return { - show: open, - Overlay: ({ - title = "Back", - showManageAction = false, - resourceId, - resourceType, - }) => { - const { sdkClient } = useCoreSdkProvider() - - const { data: organization, isLoading: isOrganizationLoading } = - useCoreApi("organization", "retrieve", []) - - const { - data: resourceTags, - isLoading, - mutate: mutateResourceTags, - } = useCoreApi( - resourceType, - "tags", - resourceId == null || isEmpty(resourceId) - ? null - : [ - resourceId, - { - fields: ["id", "name"], - pageSize: 25, - }, - ], - ) - - const tagsToSelectOptions = useCallback( - (tags: Tag[]): InputSelectValue[] => - tags.map((item) => ({ - value: item.id, - label: `${item.name}`, - meta: item, - })), - [], - ) - - const selectedOptionsToTags = useCallback( - (selectedOptions: InputSelectValue[]): Tag[] => { - if (selectedOptions.length > 0) { - return selectedOptions.map((item) => item.meta as Tag) - } - // We need to set this particular empty value because at the moment SDK expects always at least an empty tag object while updating the relationship - return [{ id: null, type: "tags" } as unknown as Tag] - }, - [], - ) - - const [selectedTags, setSelectedTags] = useState( - tagsToSelectOptions(resourceTags ?? []), - ) - - if (isLoading || isOrganizationLoading || resourceTags == null) { - return null - } - - const maxAllowedTags = organization?.tags_max_allowed_number ?? 10 - - return ( - { - void sdkClient[resourceType] - .update( - { - id: resourceId, - tags: selectedOptionsToTags(selectedTags), - }, - { - include: ["tags"], - }, - ) - .then((updatedResource) => { - const newTags = updatedResource.tags ?? [] - void mutateResourceTags(newTags as ListResponse, { - revalidate: false, - }).then(() => { - close() - }) - }) - }} - > - {t("common.update")} - - } - > - { - close() - }, - }} - toolbar={{ - buttons: - showManageAction != null && showManageAction - ? [ - { - label: t("common.manage_resource", { - resource: resourceName.toLowerCase(), - }), - variant: "secondary", - size: "small", - onClick: navigateToTagsManagement?.onClick, - }, - ] - : [], - }} - > - - {t("common.add_up_to", { - limit: maxAllowedTags, - resource: resourceName.toLowerCase(), - })} - {selectedTagsLimitReached && ( - <> - {" "} - - {t("common.limit_reached")} - - . - - )} - - ), - }} - isMulti - isSearchable - isClearable={false} - isOptionDisabled={() => selectedTags.length >= maxAllowedTags} - loadAsyncValues={async (hint) => { - if (hint.length > 0) { - return await sdkClient.tags - .list({ - fields: ["id", "name"], - filters: { - ...(!isEmpty(hint) && { name_i_cont: hint }), - }, - pageSize: 25, - }) - .then(tagsToSelectOptions) - } - return [] - }} - initialValues={[]} - defaultValue={tagsToSelectOptions(resourceTags)} - onSelect={(selectedTags) => { - if (isMultiValueSelected(selectedTags)) { - setSelectedTagsLimitReached( - selectedTags.length >= maxAllowedTags, - ) - setSelectedTags(selectedTags) - return - } - setSelectedTags([]) - }} - /> - - - ) - }, - } -} diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 80be7efa2..256aedbb9 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -80,8 +80,6 @@ export { useAppLinking } from "#helpers/useAppLinking" export { useClickAway } from "#hooks/useClickAway" export { useConfirmDialog } from "#hooks/useConfirmDialog" export { useDelayShow } from "#hooks/useDelayShow" -export { useEditMetadataOverlay } from "#hooks/useEditMetadataOverlay" -export { useEditTagsOverlay } from "#hooks/useEditTagsOverlay" export { useIsChanged } from "#hooks/useIsChanged" export { useOnBlurFromContainer } from "#hooks/useOnBlurFromContainer" export { useOverlay } from "#hooks/useOverlay" @@ -422,8 +420,15 @@ export { type ResourceListItemProps, } from "#ui/resources/ResourceListItem" export { + type KeyedMetadata, ResourceMetadata, + ResourceMetadataAddRowButton, + ResourceMetadataFormFields, + type ResourceMetadataModalProps, type ResourceMetadataProps, + resourceMetadataFormFieldsSchema, + useResourceMetadataForm, + useResourceMetadataModal, } from "#ui/resources/ResourceMetadata" export { ResourceOrderTimeline, @@ -446,9 +451,12 @@ export { export { isTaggableResource, ResourceTags, + type ResourceTagsModalProps, type ResourceTagsProps, type TaggableResource, useIsTaggableResource, + useResourceTagsForm, + useResourceTagsModal, } from "#ui/resources/ResourceTags" export { type FiltersInstructions, diff --git a/packages/app-elements/src/ui/composite/Address.tsx b/packages/app-elements/src/ui/composite/Address.tsx index f33e41f23..1c5e49e54 100644 --- a/packages/app-elements/src/ui/composite/Address.tsx +++ b/packages/app-elements/src/ui/composite/Address.tsx @@ -142,6 +142,7 @@ export const Address = withSkeletonTemplate( tag="div" variant="info" data-testid="Address-billingInfo" + size="small" > {address.billing_info} diff --git a/packages/app-elements/src/ui/composite/Modal.tsx b/packages/app-elements/src/ui/composite/Modal.tsx index 5e2fc6511..5de0c7192 100644 --- a/packages/app-elements/src/ui/composite/Modal.tsx +++ b/packages/app-elements/src/ui/composite/Modal.tsx @@ -3,6 +3,7 @@ import type React from "react" import { createContext, forwardRef, + useCallback, useContext, useEffect, useId, @@ -38,7 +39,7 @@ export type ModalProps = { /** Modal content */ children: React.ReactNode /** Max width preset */ - size?: "large" | "small" | "x-small" + size?: "large" | "medium" | "small" | "x-small" /** * Enables modal dismissal via backdrop click and Escape key. * @@ -56,6 +57,16 @@ export type ModalProps = { * Only one form per modal — nesting another `
` in the body is invalid HTML. */ onSubmit?: React.FormEventHandler + /** + * Gives out the underlying `` element. + * + * The dialog is opened with `showModal()`, so it lives in the browser's top + * layer: anything portalled to `document.body` is painted *under* it, however + * high its `z-index`. A floating panel that has to escape the body's scroll + * area — a select menu, a datepicker — therefore has to be portalled into this + * element, which is full screen and does not clip its children. + */ + dialogRef?: React.Ref } type ModalComponent = React.ForwardRefExoticComponent< @@ -75,12 +86,27 @@ const ModalRoot = ( size = "small", dismissible = false, onSubmit, + dialogRef: forwardedDialogRef, }: ModalProps, ref: React.ForwardedRef, ) => { const modalId = useId() const dialogRef = useRef(null) + const setDialogRef = useCallback( + (node: HTMLDialogElement | null) => { + dialogRef.current = node + if (typeof forwardedDialogRef === "function") { + forwardedDialogRef(node) + } else if (forwardedDialogRef != null) { + ;( + forwardedDialogRef as React.MutableRefObject + ).current = node + } + }, + [forwardedDialogRef], + ) + useEffect( function syncDialogVisibility() { const dialog = dialogRef.current @@ -145,7 +171,7 @@ const ModalRoot = ( return createPortal(
=> ({ + // react-select gives the portal wrapper `z-index: 1`, which loses against + // anything the menu is portalled past — a modal's content sits at `z-70`. + menuPortal: (style) => ({ + ...style, + zIndex: 100, + }), menu: (style) => ({ ...style, zIndex: 100, diff --git a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx index 2e9118dd0..aca3b1ef8 100644 --- a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx +++ b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx @@ -122,7 +122,7 @@ export const ResourceAddressFormFields = !showNameOrCompany || (showNameOrCompany && business === true) return ( -
+
{isNameVisible && ( void } interface ResourceAddressFormHook { @@ -33,13 +38,16 @@ interface ResourceAddressFormHook { onSubmit: React.FormEventHandler /** The address fields, to be rendered in the scrollable area */ fields: ReactNode - /** Submit button and API error, to be rendered in the pinned area */ - submitButton: ReactNode + /** + * The controls that stay put while the fields scroll — cancel, submit — plus + * the API error. To be rendered in the pinned area. + */ + footer: ReactNode } /** * Builds the pieces of an address create/update form, leaving their placement to - * the caller: `fields` and `submitButton` can go in separate containers — such as + * the caller: `fields` and `footer` can go in separate containers — such as * a `Modal`'s `Body` and `Footer` — as long as a `FormProvider` spread with * `methods` wraps both and the enclosing `` is given `onSubmit`. */ @@ -51,6 +59,7 @@ export function useResourceAddressForm({ onUpdate, onCreate, isOpen = true, + onCancel, }: UseResourceAddressFormProps): ResourceAddressFormHook { const methods = useForm({ defaultValues: address ?? undefined, @@ -103,16 +112,28 @@ export function useResourceAddressForm({ showNotes={showNotes} /> ), - submitButton: ( + footer: ( <> - +
+ {onCancel != null && ( + + )} + +
), diff --git a/packages/app-elements/src/ui/resources/ResourceAddress/useResourceAddressModal.tsx b/packages/app-elements/src/ui/resources/ResourceAddress/useResourceAddressModal.tsx index 46b1459ea..d205ec6d8 100644 --- a/packages/app-elements/src/ui/resources/ResourceAddress/useResourceAddressModal.tsx +++ b/packages/app-elements/src/ui/resources/ResourceAddress/useResourceAddressModal.tsx @@ -46,8 +46,9 @@ export const useResourceAddressModal = ({ } }, [canEditAddress]) - const { methods, onSubmit, fields, submitButton } = useResourceAddressForm({ + const { methods, onSubmit, fields, footer } = useResourceAddressForm({ isOpen, + onCancel: close, address, showBillingInfo, requiresBillingInfo, @@ -74,7 +75,7 @@ export const useResourceAddressModal = ({ @@ -83,7 +84,7 @@ export const useResourceAddressModal = ({ `${address == null ? t("common.new") : t("common.edit")} ${t("resources.addresses.name").toLowerCase()}`} {fields} - {submitButton} + {footer} ) : null, diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.test.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.test.tsx index 5a8ff1acf..903a99424 100644 --- a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.test.tsx +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.test.tsx @@ -16,7 +16,7 @@ const setup = async (): Promise => { , @@ -60,7 +60,7 @@ describe("ResourceMetadata", () => { expect(header?.querySelectorAll("button")).toHaveLength(1) // (the trigger icon carries an `aria-label` of its own, hence exact matches) expect( - container.querySelector('[aria-label="common.edit"]'), + container.querySelector('[aria-label="common.edit common.metadata"]'), ).not.toBeInTheDocument() expect( container.querySelector('[aria-label="common.view_json"]'), @@ -73,10 +73,41 @@ describe("ResourceMetadata", () => { // edit, plus the JSON view since this resource has metadata expect( - container.querySelector('[aria-label="common.edit"]'), + container.querySelector('[aria-label="common.edit common.metadata"]'), ).toBeInTheDocument() expect( container.querySelector('[aria-label="common.view_json"]'), ).toBeInTheDocument() }) + + // `gdpr_preferences` is an object, which this form cannot edit: its row is kept + // in the form values — hidden — so the value survives an update. Removing a row + // above it used to shift it onto the type of whichever entry had been sitting + // at its new index, turning it into an editable text input. + it("keeps a non-editable entry hidden after a row above it is removed", async () => { + const { container, baseElement } = await setup() + + const trigger = container.querySelector("header")?.querySelector("button") + await act(async () => { + fireEvent.click(trigger as HTMLButtonElement) + }) + await act(async () => { + fireEvent.click( + container.querySelector( + '[aria-label="common.edit common.metadata"]', + ) as HTMLElement, + ) + }) + + // the two string entries, while `gdpr_preferences` has no row on screen + const removeButtons = (): HTMLElement[] => + Array.from(baseElement.querySelectorAll('[aria-label="common.remove"]')) + expect(removeButtons()).toHaveLength(2) + + await act(async () => { + fireEvent.click(removeButtons()[0] as HTMLElement) + }) + + expect(removeButtons()).toHaveLength(1) + }) }) diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.tsx index 7b536f8e0..0683f9800 100644 --- a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.tsx +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadata.tsx @@ -1,10 +1,6 @@ import type { ListableResourceType } from "@commercelayer/sdk" import { isEmpty } from "lodash-es" import { isMockedId } from "#helpers/mocks" -import { - type EditMetadataOverlayProps, - useEditMetadataOverlay, -} from "#hooks/useEditMetadataOverlay" import { useViewJsonOverlay } from "#hooks/useViewJsonOverlay" import { useCoreApi } from "#providers/CoreSdkProvider" import { t } from "#providers/I18NProvider" @@ -17,9 +13,14 @@ import { Spacer } from "#ui/atoms/Spacer" import { Text } from "#ui/atoms/Text" import { Dropdown, DropdownItem } from "#ui/composite/Dropdown" import { useSurfaceVariant } from "#ui/internals/overlayContext" +import { + type ResourceMetadataModalProps, + useResourceMetadataModal, +} from "./useResourceMetadataModal" +import { isUpdatableType } from "./utils" -interface MetadataOverlay - extends Omit {} +interface MetadataModal + extends Omit {} export interface ResourceMetadataProps { /** @@ -33,28 +34,31 @@ export interface ResourceMetadataProps { resourceType: ListableResourceType resourceId: string /** - * Edit overlay configuration + * Edit modal configuration */ - overlay?: MetadataOverlay + modal?: MetadataModal } -export const updatableTypes = ["string", "number", "boolean"] as const -export type UpdatableType = (typeof updatableTypes)[number] - -export const isUpdatableType = (value: any): value is UpdatableType => { - return updatableTypes.includes(typeof value as UpdatableType) -} +export { + isUpdatableType, + type UpdatableType, + updatableTypes, +} from "./utils" /** * This component provides an all-in-one visualization and editing interface for the `metadata` attribute of a given resource. * More in detail the `metadata` attribute is a JSON object, customizable for several purposes, and this component will allow to show and manage its keys with a simple (string kind) values. */ export const ResourceMetadata = withSkeletonTemplate( - ({ resourceType, resourceId, overlay, variant }) => { + ({ resourceType, resourceId, modal, variant }) => { // the hook runs unconditionally; the prop only wins afterwards const inferredSurface = useSurfaceVariant() const surface = variant ?? inferredSurface - const { Overlay: EditMetadataOverlay, show } = useEditMetadataOverlay() + const { metadataModal, openMetadataModal } = useResourceMetadataModal({ + title: modal?.title, + resourceId, + resourceType, + }) const { JsonOverlay, showJsonOverlay } = useViewJsonOverlay() const { canUser } = useTokenProvider() @@ -81,7 +85,6 @@ export const ResourceMetadata = withSkeletonTemplate(
( {canUser("update", resourceType) && ( { - show() + openMetadataModal() }} /> )} @@ -126,57 +129,53 @@ export const ResourceMetadata = withSkeletonTemplate( ) } > - {!isEmpty(resourceData?.metadata) ? ( - - {Object.entries(resourceData?.metadata ?? []).map( - ([metadataKey, metadataValue], idx) => { - return ( -
- - {metadataKey}: - - + {!isEmpty(resourceData?.metadata) ? ( + + {Object.entries(resourceData?.metadata ?? []).map( + ([metadataKey, metadataValue], idx) => { + return ( +
- {isUpdatableType(metadataValue) - ? metadataValue.toString() - : "[...]"} - -
- ) - }, - )} -
- ) : ( - + + {metadataKey}: + + + {isUpdatableType(metadataValue) + ? metadataValue.toString() + : "[...]"} + +
+ ) + }, + )} +
+ ) : ( {t("common.no_metadata")} - - )} + )} +
- + {metadataModal}
) }, diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataForm.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataForm.tsx deleted file mode 100644 index 8949dada5..000000000 --- a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataForm.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { Metadata } from "@commercelayer/sdk" -import { zodResolver } from "@hookform/resolvers/zod" -import { Fragment, type JSX, useMemo, useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" -import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" -import { t } from "#providers/I18NProvider" -import { Button } from "#ui/atoms/Button" -import { Icon } from "#ui/atoms/Icon" -import { Section } from "#ui/atoms/Section" -import { withSkeletonTemplate } from "#ui/atoms/SkeletonTemplate" -import { Spacer } from "#ui/atoms/Spacer" -import { ListItem } from "#ui/composite/ListItem" -import { HookedForm } from "#ui/forms/Form" -import { HookedInput } from "#ui/forms/Input" -import { HookedInputCheckbox } from "#ui/forms/InputCheckbox" -import { HookedValidationApiError } from "#ui/forms/ReactHookForm" -import { isUpdatableType, type ResourceMetadataProps } from "./ResourceMetadata" -import { groupMetadataKeys } from "./utils" - -const metadataForm = z - .object({ - metadata: z - .object({ - key: z.string(), - value: z.unknown(), - }) - .array(), - }) - .superRefine((data, ctx) => { - const grouped = groupMetadataKeys(data.metadata) - - Object.entries(grouped).forEach(([_key, group]) => { - if (group.count > 1) { - group.indexes.forEach((index) => { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [`metadata.${index}.key`], - message: "Key already used", - }) - }) - } - }) - }) - -export const ResourceMetadataForm = withSkeletonTemplate<{ - resourceId: ResourceMetadataProps["resourceId"] - resourceType: ResourceMetadataProps["resourceType"] - onSubmitted: () => void -}>(({ resourceId, resourceType, onSubmitted }) => { - const { - data: resourceData, - isLoading, - mutate: mutateResource, - } = useCoreApi(resourceType, "retrieve", [ - resourceId, - { - fields: ["metadata"], - }, - ]) - - const [apiError, setApiError] = useState(undefined) - const { sdkClient } = useCoreSdkProvider() - - const keyedMetadata: KeyedMetadata[] = useMemo(() => { - if (resourceData?.metadata != null) { - const result = Object.entries(resourceData.metadata).map( - ([metadataKey, metadataValue]) => ({ - key: metadataKey, - value: metadataValue, - }), - ) - - if (result.length === 0) { - result.push({ - key: "", - value: "", - }) - } - - return result - } - return [] - }, [resourceData?.metadata]) - - const methods = useForm({ - defaultValues: { metadata: keyedMetadata }, - resolver: zodResolver(metadataForm), - }) - - const watchedMetadata = methods.watch("metadata") - - const addNewRow = (): void => { - // A new array: `setValue` with the very same reference leaves `watch` - // returning what it already returned, so the row would never show up. - const nextMetadata = [...watchedMetadata, { key: "", value: "" }] - methods.setValue("metadata", nextMetadata) - setTimeout(() => { - methods.setFocus(`metadata.${nextMetadata.length - 1}.key`, { - shouldSelect: true, - }) - }, 200) - } - - const editInputComponent = ( - metadata: KeyedMetadata, - idx: number, - ): JSX.Element | undefined => { - switch (typeof metadata.value) { - case "string": - return - case "number": - return ( - - ) - case "boolean": - return - default: - return undefined - } - } - - if (isLoading) { - return null - } - - return ( - { - const sdkMetadata: Metadata = {} - formValues.metadata?.forEach((m, idx) => { - // I need to check if the original value of a metadata entry was `number` to force it to be parsed as `float` because input field behavior will change it to `string` by default - if ( - keyedMetadata[idx] != null && - typeof keyedMetadata[idx]?.value === "number" - ) { - sdkMetadata[m.key] = parseFloat(m.value as string) - } else { - sdkMetadata[m.key] = m.value - } - }) - - void sdkClient[resourceType] - .update( - { - id: resourceId, - metadata: sdkMetadata, - }, - { - // @ts-expect-error "Expression produces a union type that is too complex to represent" - fields: ["metadata"], - }, - ) - .then((updatedResource) => { - void mutateResource(updatedResource).then(() => { - onSubmitted() - }) - }) - .catch((error) => { - setApiError(error) - }) - }} - > - -
- {watchedMetadata.map((metadata, idx) => { - const originalMetadata = keyedMetadata[idx] ?? metadata - if (!isUpdatableType(originalMetadata.value)) { - // biome-ignore lint/suspicious/noArrayIndexKey: Using index as key is acceptable here since items are static - return - } - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: Using index as key is acceptable here since items are static - -
- -
- {editInputComponent(originalMetadata, idx)} -
-
- -
- ) - })} - - - -
-
- - - - -
- ) -}) - -type KeyedMetadata = z.infer["metadata"][number] diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataFormFields.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataFormFields.tsx new file mode 100644 index 000000000..31bcd43af --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/ResourceMetadataFormFields.tsx @@ -0,0 +1,196 @@ +import { Fragment, type JSX } from "react" +import { useFormContext, useWatch } from "react-hook-form" +import { z } from "zod" +import { t } from "#providers/I18NProvider" +import { Button } from "#ui/atoms/Button" +import { Icon } from "#ui/atoms/Icon" +import { withSkeletonTemplate } from "#ui/atoms/SkeletonTemplate" +import { ListItem } from "#ui/composite/ListItem" +import { HookedInput } from "#ui/forms/Input" +import { HookedInputCheckbox } from "#ui/forms/InputCheckbox" +import { groupMetadataKeys, type UpdatableType, updatableTypes } from "./utils" + +export const resourceMetadataFormFieldsSchema = z + .object({ + metadata: z + .object({ + key: z.string(), + value: z.unknown(), + /** + * The type the value had when the resource was read, carried on the row + * itself rather than looked up by position in the original entries: + * removing a row shifts every row after it, so a lookup by index would + * hand the survivors the type of the entry that used to sit there. + * + * `"other"` marks an entry this form cannot edit (an object, an array); + * its row stays in the array, hidden, so the value survives the update. + */ + valueType: z.enum([...updatableTypes, "other"]), + }) + .array(), + }) + .superRefine((data, ctx) => { + const grouped = groupMetadataKeys(data.metadata) + + Object.entries(grouped).forEach(([_key, group]) => { + if (group.count > 1) { + group.indexes.forEach((index) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [`metadata.${index}.key`], + message: "Key already used", + }) + }) + } + }) + }) + +export type KeyedMetadata = z.infer< + typeof resourceMetadataFormFieldsSchema +>["metadata"][number] + +/** Builds a form row out of a `metadata` entry, tagging it with its type */ +export const toKeyedMetadata = (key: string, value: unknown): KeyedMetadata => { + const valueType = typeof value + + return { + key, + value, + valueType: updatableTypes.includes(valueType as UpdatableType) + ? (valueType as UpdatableType) + : "other", + } +} + +const editInputComponent = ( + metadata: KeyedMetadata, + idx: number, +): JSX.Element | undefined => { + switch (metadata.valueType) { + case "string": + return + case "number": + return ( + + ) + case "boolean": + return + default: + return undefined + } +} + +/** + * Appends an empty row to the `metadata` form values and focuses its key input. + * Lives outside the rows so the control can be rendered apart from them — the + * modal keeps it pinned in the footer while the rows scroll. + */ +function useAddMetadataRow(): () => void { + const { control, setValue, setFocus } = useFormContext() + const watchedMetadata: KeyedMetadata[] = + useWatch({ control, name: "metadata" }) ?? [] + + return () => { + // A new array: `setValue` given the very same reference leaves the watch + // returning what it already returned, so the row would never show up. + const nextMetadata = [...watchedMetadata, toKeyedMetadata("", "")] + setValue("metadata", nextMetadata) + setTimeout(() => { + setFocus(`metadata.${nextMetadata.length - 1}.key`, { + shouldSelect: true, + }) + }, 200) + } +} + +/** + * The control that appends an empty `metadata` row. Reads the form through + * context, so it can sit anywhere inside the `FormProvider` set up by + * `useResourceMetadataForm` — in particular in a container of its own, away from + * the rows it adds to. + */ +export const ResourceMetadataAddRowButton = withSkeletonTemplate(() => { + const addNewRow = useAddMetadataRow() + + return ( + + ) +}) + +ResourceMetadataAddRowButton.displayName = "ResourceMetadataAddRowButton" + +/** + * The editable rows of a resource's `metadata`. Reads the form through context, + * so it can sit anywhere inside the `FormProvider` set up by + * `useResourceMetadataForm`. + */ +export const ResourceMetadataFormFields = withSkeletonTemplate(() => { + const { control, setValue } = useFormContext() + // `useWatch` rather than `methods.watch`: the subscription then belongs to + // this component, so typing in a row re-renders the rows and not the whole + // page hosting the modal + const watchedMetadata: KeyedMetadata[] = + useWatch({ control, name: "metadata" }) ?? [] + + return ( + // the rows space themselves out here rather than through each `ListItem`'s + // own padding, so the gap stays the same between any two of them +
+ {watchedMetadata.map((metadata, idx) => { + if (metadata.valueType === "other") { + // biome-ignore lint/suspicious/noArrayIndexKey: Using index as key is acceptable here since items are static + return + } + + return ( + +
+ +
+ {editInputComponent(metadata, idx)} +
+
+ +
+ ) + })} +
+ ) +}) + +ResourceMetadataFormFields.displayName = "ResourceMetadataFormFields" diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/index.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/index.tsx index 9e534cdcc..ee2e62b5b 100644 --- a/packages/app-elements/src/ui/resources/ResourceMetadata/index.tsx +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/index.tsx @@ -2,3 +2,14 @@ export { ResourceMetadata, type ResourceMetadataProps, } from "./ResourceMetadata" +export { + type KeyedMetadata, + ResourceMetadataAddRowButton, + ResourceMetadataFormFields, + resourceMetadataFormFieldsSchema, +} from "./ResourceMetadataFormFields" +export { useResourceMetadataForm } from "./useResourceMetadataForm" +export { + type ResourceMetadataModalProps, + useResourceMetadataModal, +} from "./useResourceMetadataModal" diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataForm.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataForm.tsx new file mode 100644 index 000000000..0c19fef53 --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataForm.tsx @@ -0,0 +1,175 @@ +import type { Metadata } from "@commercelayer/sdk" +import { zodResolver } from "@hookform/resolvers/zod" +import { type ReactNode, useEffect, useMemo, useState } from "react" +import { type UseFormReturn, useForm } from "react-hook-form" +import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" +import { t } from "#providers/I18NProvider" +import { Button } from "#ui/atoms/Button" +import { HookedValidationApiError } from "#ui/forms/ReactHookForm" +import type { ResourceMetadataProps } from "./ResourceMetadata" +import { + type KeyedMetadata, + ResourceMetadataAddRowButton, + ResourceMetadataFormFields, + resourceMetadataFormFieldsSchema, + toKeyedMetadata, +} from "./ResourceMetadataFormFields" + +export interface UseResourceMetadataFormProps { + resourceId: ResourceMetadataProps["resourceId"] + resourceType: ResourceMetadataProps["resourceType"] + /** + * Whether the form is currently visible. The form starts over whenever this + * flips to `true`, so a container that keeps it mounted across openings still + * shows the metadata as they are now rather than what was last typed. + */ + isOpen?: boolean + /** Called once the resource has been updated */ + onSubmitted?: () => void + /** + * Called when the user gives up on the edit. Leaving it out drops the cancel + * control, for a container that has a way out of its own. + */ + onCancel?: () => void +} + +interface ResourceMetadataFormHook { + /** `react-hook-form` context, to be spread on a `FormProvider` around both slots */ + methods: UseFormReturn + /** Submit handler for the `` hosting the fields (e.g. `Modal`'s `onSubmit`) */ + onSubmit: React.FormEventHandler + /** The metadata rows, to be rendered in the scrollable area */ + fields: ReactNode + /** + * The controls that stay put while the rows scroll — add a row, cancel, + * submit — plus the API error. To be rendered in the pinned area. + */ + footer: ReactNode +} + +/** + * Builds the pieces of a `metadata` update form, leaving their placement to the + * caller: `fields` and `footer` can go in separate containers — such as a + * `Modal`'s `Body` and `Footer` — as long as a `FormProvider` spread with + * `methods` wraps both and the enclosing `` is given `onSubmit`. + */ +export function useResourceMetadataForm({ + resourceId, + resourceType, + isOpen = true, + onSubmitted, + onCancel, +}: UseResourceMetadataFormProps): ResourceMetadataFormHook { + const { + data: resourceData, + isLoading, + mutate: mutateResource, + } = useCoreApi(resourceType, "retrieve", [ + resourceId, + { + fields: ["metadata"], + }, + ]) + + const [apiError, setApiError] = useState(undefined) + const { sdkClient } = useCoreSdkProvider() + + const keyedMetadata: KeyedMetadata[] = useMemo(() => { + if (resourceData?.metadata != null) { + const result = Object.entries(resourceData.metadata).map( + ([metadataKey, metadataValue]) => + toKeyedMetadata(metadataKey, metadataValue), + ) + + if (result.length === 0) { + result.push(toKeyedMetadata("", "")) + } + + return result + } + return [] + }, [resourceData?.metadata]) + + const methods = useForm({ + defaultValues: { metadata: keyedMetadata }, + resolver: zodResolver(resourceMetadataFormFieldsSchema), + }) + + // The resource is fetched while the form is already mounted, so the rows are + // filled in by a reset rather than by `defaultValues` alone. + useEffect(() => { + if (isOpen) { + methods.reset({ metadata: keyedMetadata }) + setApiError(undefined) + } + }, [isOpen, keyedMetadata]) + + const handleSubmit = methods.handleSubmit(async (formValues) => { + const sdkMetadata: Metadata = {} + formValues.metadata?.forEach((m: KeyedMetadata) => { + // a number input hands back a string, so an entry that came in as a number + // is parsed back into one. The type is read off the row rather than off the + // entries as they were read, which rows removed in the meantime would have + // shifted out of step. + if (m.valueType === "number") { + sdkMetadata[m.key] = parseFloat(m.value as string) + } else { + sdkMetadata[m.key] = m.value + } + }) + + await sdkClient[resourceType] + .update( + { + id: resourceId, + metadata: sdkMetadata, + }, + { + // @ts-expect-error "Expression produces a union type that is too complex to represent" + fields: ["metadata"], + }, + ) + .then(async (updatedResource) => { + await mutateResource(updatedResource) + onSubmitted?.() + }) + .catch((error) => { + setApiError(error) + }) + }) + + return { + methods, + onSubmit: (event) => { + void handleSubmit(event) + }, + fields: , + footer: ( + <> + +
+ +
+ {onCancel != null && ( + + )} + +
+
+ + ), + } +} diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataModal.tsx b/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataModal.tsx new file mode 100644 index 000000000..43785b6a7 --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/useResourceMetadataModal.tsx @@ -0,0 +1,82 @@ +import { type ReactNode, useCallback, useState } from "react" +import { FormProvider } from "react-hook-form" +import { t } from "#providers/I18NProvider" +import { useTokenProvider } from "#providers/TokenProvider" +import { Modal } from "#ui/composite/Modal" +import type { ResourceMetadataProps } from "./ResourceMetadata" +import { useResourceMetadataForm } from "./useResourceMetadataForm" + +export interface ResourceMetadataModalProps { + /** + * Optional heading for the modal, replacing the default `Edit metadata` + */ + title?: string + resourceId: ResourceMetadataProps["resourceId"] + resourceType: ResourceMetadataProps["resourceType"] +} + +interface ResourceMetadataModalHook { + /** The modal itself — render it as is, its visibility is driven by the hook */ + metadataModal: ReactNode + /** Opens the modal, when the user is allowed to */ + openMetadataModal: () => void +} + +export function useResourceMetadataModal({ + title, + resourceId, + resourceType, +}: ResourceMetadataModalProps): ResourceMetadataModalHook { + const { canUser } = useTokenProvider() + const [isOpen, setIsOpen] = useState(false) + + const canEditMetadata = canUser("update", resourceType) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const openMetadataModal = useCallback(() => { + if (canEditMetadata) { + setIsOpen(true) + } + }, [canEditMetadata]) + + const { methods, onSubmit, fields, footer } = useResourceMetadataForm({ + resourceId, + resourceType, + isOpen, + onSubmitted: close, + onCancel: close, + }) + + return { + // an element rather than a component: React then reconciles it by type on + // every render, so the `Modal` is updated in place instead of being + // remounted — a remount would skip the cleanup that releases the body + // scroll lock, leaving the page stuck + metadataModal: canEditMetadata ? ( + // no DOM of its own, so it can span the modal's slots and let the submit + // button in the footer drive the fields in the body + + + + {title ?? + t("common.edit_resource", { + resource: t("common.metadata").toLowerCase(), + })} + + {fields} + {footer} + + + ) : null, + openMetadataModal, + } +} diff --git a/packages/app-elements/src/ui/resources/ResourceMetadata/utils.ts b/packages/app-elements/src/ui/resources/ResourceMetadata/utils.ts index b115a4cb2..8cf022648 100644 --- a/packages/app-elements/src/ui/resources/ResourceMetadata/utils.ts +++ b/packages/app-elements/src/ui/resources/ResourceMetadata/utils.ts @@ -1,3 +1,10 @@ +export const updatableTypes = ["string", "number", "boolean"] as const +export type UpdatableType = (typeof updatableTypes)[number] + +export const isUpdatableType = (value: any): value is UpdatableType => { + return updatableTypes.includes(typeof value as UpdatableType) +} + export function groupMetadataKeys( data: Array<{ key: string; value?: unknown }>, ): GroupedMetadataKeys { diff --git a/packages/app-elements/src/ui/resources/ResourceTags.test.tsx b/packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.test.tsx similarity index 82% rename from packages/app-elements/src/ui/resources/ResourceTags.test.tsx rename to packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.test.tsx index 6c93e0f90..4c8663804 100644 --- a/packages/app-elements/src/ui/resources/ResourceTags.test.tsx +++ b/packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.test.tsx @@ -11,7 +11,7 @@ const setup = async () => , @@ -28,8 +28,9 @@ describe("ResourceTags", () => { const header = container.querySelector("header") expect(header?.querySelectorAll("button")).toHaveLength(1) + // (the trigger icon carries an `aria-label` of its own, hence exact matches) expect( - container.querySelector('[aria-label="common.edit"]'), + container.querySelector('[aria-label="common.edit resources.tags.name"]'), ).not.toBeInTheDocument() await act(async () => { @@ -37,7 +38,7 @@ describe("ResourceTags", () => { }) expect( - container.querySelector('[aria-label="common.edit"]'), + container.querySelector('[aria-label="common.edit resources.tags.name"]'), ).toBeInTheDocument() }) }) diff --git a/packages/app-elements/src/ui/resources/ResourceTags.tsx b/packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.tsx similarity index 89% rename from packages/app-elements/src/ui/resources/ResourceTags.tsx rename to packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.tsx index c29537e11..fe20cbdf7 100644 --- a/packages/app-elements/src/ui/resources/ResourceTags.tsx +++ b/packages/app-elements/src/ui/resources/ResourceTags/ResourceTags.tsx @@ -5,10 +5,6 @@ import type { } from "@commercelayer/sdk" import isEmpty from "lodash-es/isEmpty" import { isMockedId } from "#helpers/mocks" -import { - type EditTagsOverlayProps, - useEditTagsOverlay, -} from "#hooks/useEditTagsOverlay" import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" import { t } from "#providers/I18NProvider" import { useTokenProvider } from "#providers/TokenProvider" @@ -20,6 +16,10 @@ import { Tag as TagUi } from "#ui/atoms/Tag" import { Text } from "#ui/atoms/Text" import { Dropdown, DropdownItem } from "#ui/composite/Dropdown" import { useSurfaceVariant } from "#ui/internals/overlayContext" +import { + type ResourceTagsModalProps, + useResourceTagsModal, +} from "./useResourceTagsModal" export type TaggableResource = TaggableResourceType @@ -51,8 +51,8 @@ export function useIsTaggableResource( return isTaggableResource(resourceType, sdkClient) } -interface TagsOverlay - extends Omit {} +interface TagsModal + extends Omit {} export interface ResourceTagsProps { /** @@ -66,9 +66,9 @@ export interface ResourceTagsProps { resourceType: TaggableResource resourceId: string /** - * Edit overlay configuration + * Edit modal configuration */ - overlay?: TagsOverlay + modal?: TagsModal /** * Optional onTagClick function to define the click behavior of single tag in visualization */ @@ -79,7 +79,7 @@ export interface ResourceTagsProps { * This component generates an all-in-one visualization and editing interface for managing tags relationship of requested resource. */ export const ResourceTags = withSkeletonTemplate( - ({ resourceType, resourceId, overlay, onTagClick, variant }) => { + ({ resourceType, resourceId, modal, onTagClick, variant }) => { // the hook runs unconditionally; the prop only wins afterwards const inferredSurface = useSurfaceVariant() const surface = variant ?? inferredSurface @@ -97,7 +97,12 @@ export const ResourceTags = withSkeletonTemplate( ], ) - const { Overlay: EditTagsOverlay, show } = useEditTagsOverlay() + const { tagsModal, openTagsModal } = useResourceTagsModal({ + title: modal?.title, + showManageAction: modal?.showManageAction, + resourceId, + resourceType, + }) const { canUser } = useTokenProvider() @@ -125,9 +130,9 @@ export const ResourceTags = withSkeletonTemplate( dropdownItems={ { - show() + openTagsModal() }} /> } @@ -163,12 +168,7 @@ export const ResourceTags = withSkeletonTemplate( })}
)} - + {tagsModal} ) }, diff --git a/packages/app-elements/src/ui/resources/ResourceTags/index.tsx b/packages/app-elements/src/ui/resources/ResourceTags/index.tsx new file mode 100644 index 000000000..57a9f0b8a --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceTags/index.tsx @@ -0,0 +1,12 @@ +export { + isTaggableResource, + ResourceTags, + type ResourceTagsProps, + type TaggableResource, + useIsTaggableResource, +} from "./ResourceTags" +export { useResourceTagsForm } from "./useResourceTagsForm" +export { + type ResourceTagsModalProps, + useResourceTagsModal, +} from "./useResourceTagsModal" diff --git a/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsForm.tsx b/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsForm.tsx new file mode 100644 index 000000000..1e02df9e2 --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsForm.tsx @@ -0,0 +1,271 @@ +import type { ListResponse, Tag } from "@commercelayer/sdk" +import isEmpty from "lodash-es/isEmpty" +import { type ReactNode, useCallback, useEffect, useState } from "react" +import { navigateTo } from "#helpers/appsNavigation" +import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" +import { useTranslation } from "#providers/I18NProvider" +import { useTokenProvider } from "#providers/TokenProvider" +import { Button } from "#ui/atoms/Button" +import { Text } from "#ui/atoms/Text" +import { + InputSelect, + type InputSelectValue, + isMultiValueSelected, +} from "#ui/forms/InputSelect" +import type { ResourceTagsProps } from "./ResourceTags" + +export interface UseResourceTagsFormProps { + resourceId: ResourceTagsProps["resourceId"] + resourceType: ResourceTagsProps["resourceType"] + /** + * Whether the form is currently visible. The selection starts over whenever this + * flips to `true`, so a container that keeps it mounted across openings still + * shows the tags as they are now rather than what was last picked. + */ + isOpen?: boolean + /** + * Where the autocomplete panel is attached in the DOM. + * + * Left out, the panel renders inside the field, so a container that clips or + * scrolls — a modal body — cuts it off. Pointed at an element that does not + * clip, the panel escapes the container while staying anchored to the field. + */ + menuPortalTarget?: HTMLElement | null + /** + * Whether the link to the tags app is shown beside the submit button. + * @default false + */ + showManageAction?: boolean + /** Called once the resource has been updated */ + onSubmitted?: () => void + /** + * Called when the user gives up on the edit. Leaving it out drops the cancel + * control, for a container that has a way out of its own. + */ + onCancel?: () => void +} + +interface ResourceTagsFormHook { + /** Submit handler for the `` hosting the fields (e.g. `Modal`'s `onSubmit`) */ + onSubmit: React.FormEventHandler + /** The tags selection, to be rendered in the scrollable area */ + fields: ReactNode + /** + * The controls that stay put while the field scrolls — manage, cancel, + * submit. To be rendered in the pinned area. + */ + footer: ReactNode +} + +const tagsToSelectOptions = (tags: Tag[]): InputSelectValue[] => + tags.map((item) => ({ + value: item.id, + label: `${item.name}`, + meta: item, + })) + +const selectedOptionsToTags = (selectedOptions: InputSelectValue[]): Tag[] => { + if (selectedOptions.length > 0) { + return selectedOptions.map((item) => item.meta as Tag) + } + // We need to set this particular empty value because at the moment SDK expects always at least an empty tag object while updating the relationship + return [{ id: null, type: "tags" } as unknown as Tag] +} + +/** + * Builds the pieces of a `tags` update form, leaving their placement to the + * caller: `fields` and `footer` can go in separate containers — such as a + * `Modal`'s `Body` and `Footer` — as long as the enclosing `` is given + * `onSubmit`. + */ +export function useResourceTagsForm({ + resourceId, + resourceType, + isOpen = true, + menuPortalTarget, + showManageAction = false, + onSubmitted, + onCancel, +}: UseResourceTagsFormProps): ResourceTagsFormHook { + const { settings } = useTokenProvider() + const { t } = useTranslation() + const { sdkClient } = useCoreSdkProvider() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [selectedTags, setSelectedTags] = useState([]) + const [selectedTagsLimitReached, setSelectedTagsLimitReached] = + useState(false) + + const resourceName = t("resources.tags.name_other") + + const navigateToTagsManagement = navigateTo({ + destination: { + app: "tags", + mode: settings.mode, + }, + }) + + const { data: organization, isLoading: isOrganizationLoading } = useCoreApi( + "organization", + "retrieve", + [], + ) + + const { + data: resourceTags, + isLoading, + mutate: mutateResourceTags, + } = useCoreApi( + resourceType, + "tags", + resourceId == null || isEmpty(resourceId) + ? null + : [ + resourceId, + { + fields: ["id", "name"], + pageSize: 25, + }, + ], + ) + + const maxAllowedTags = organization?.tags_max_allowed_number ?? 10 + + // The tags are fetched while the form is already mounted, so the selection is + // filled in here rather than by the initial state alone. + useEffect(() => { + if (isOpen) { + const currentTags = tagsToSelectOptions(resourceTags ?? []) + setSelectedTags(currentTags) + setSelectedTagsLimitReached(currentTags.length >= maxAllowedTags) + } + }, [isOpen, resourceTags, maxAllowedTags]) + + const handleSubmit = useCallback>( + (event) => { + event.preventDefault() + setIsSubmitting(true) + + void sdkClient[resourceType] + .update( + { + id: resourceId, + tags: selectedOptionsToTags(selectedTags), + }, + { + include: ["tags"], + }, + ) + .then(async (updatedResource) => { + const newTags = updatedResource.tags ?? [] + await mutateResourceTags(newTags as ListResponse, { + revalidate: false, + }) + onSubmitted?.() + }) + .finally(() => { + setIsSubmitting(false) + }) + }, + [ + sdkClient, + resourceType, + resourceId, + selectedTags, + mutateResourceTags, + onSubmitted, + ], + ) + + return { + onSubmit: handleSubmit, + fields: ( + + {t("common.add_up_to", { + limit: maxAllowedTags, + resource: resourceName.toLowerCase(), + })} + {selectedTagsLimitReached && ( + <> + {" "} + + {t("common.limit_reached")} + + . + + )} + + ), + }} + isMulti + isSearchable + menuPortalTarget={menuPortalTarget} + isClearable={false} + isOptionDisabled={() => selectedTags.length >= maxAllowedTags} + loadAsyncValues={async (hint) => { + if (hint.length > 0) { + return await sdkClient.tags + .list({ + fields: ["id", "name"], + filters: { + ...(!isEmpty(hint) && { name_i_cont: hint }), + }, + pageSize: 25, + }) + .then(tagsToSelectOptions) + } + return [] + }} + initialValues={[]} + value={selectedTags} + onSelect={(selectedTags) => { + if (isMultiValueSelected(selectedTags)) { + setSelectedTagsLimitReached(selectedTags.length >= maxAllowedTags) + setSelectedTags([...selectedTags]) + return + } + setSelectedTags([]) + }} + /> + ), + footer: ( +
+ {showManageAction && navigateToTagsManagement != null ? ( + + ) : ( +
+ )} +
+ {onCancel != null && ( + + )} + +
+
+ ), + } +} diff --git a/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsModal.tsx b/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsModal.tsx new file mode 100644 index 000000000..70fcc2a2e --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceTags/useResourceTagsModal.tsx @@ -0,0 +1,91 @@ +import { type ReactNode, useCallback, useState } from "react" +import { t } from "#providers/I18NProvider" +import { useTokenProvider } from "#providers/TokenProvider" +import { Modal } from "#ui/composite/Modal" +import type { ResourceTagsProps } from "./ResourceTags" +import { useResourceTagsForm } from "./useResourceTagsForm" + +export interface ResourceTagsModalProps { + /** + * Optional heading for the modal, replacing the default `Edit tags` + */ + title?: string + /** + * Optional setting to define if tags app management link is to be shown in the modal footer + */ + showManageAction?: boolean + resourceId: ResourceTagsProps["resourceId"] + resourceType: ResourceTagsProps["resourceType"] +} + +interface ResourceTagsModalHook { + /** The modal itself — render it as is, its visibility is driven by the hook */ + tagsModal: ReactNode + /** Opens the modal, when the user is allowed to */ + openTagsModal: () => void +} + +export function useResourceTagsModal({ + title, + showManageAction, + resourceId, + resourceType, +}: ResourceTagsModalProps): ResourceTagsModalHook { + const { canUser } = useTokenProvider() + const [isOpen, setIsOpen] = useState(false) + // State rather than a ref: the select is rendered on the same pass as the + // modal, so it needs a re-render once the dialog element exists to pick it up + // as its portal target. + const [dialogElement, setDialogElement] = useState( + null, + ) + + const canEditTags = canUser("update", resourceType) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const openTagsModal = useCallback(() => { + if (canEditTags) { + setIsOpen(true) + } + }, [canEditTags]) + + const { onSubmit, fields, footer } = useResourceTagsForm({ + resourceId, + resourceType, + isOpen, + menuPortalTarget: dialogElement, + showManageAction, + onSubmitted: close, + onCancel: close, + }) + + return { + // an element rather than a component: React then reconciles it by type on + // every render, so the `Modal` is updated in place instead of being + // remounted — a remount would skip the cleanup that releases the body + // scroll lock, leaving the page stuck + tagsModal: canEditTags ? ( + + + {title ?? + t("common.edit_resource", { + resource: t("resources.tags.name_other").toLowerCase(), + })} + + {fields} + {footer} + + ) : null, + openTagsModal, + } +} diff --git a/packages/docs/src/stories/composite/Modal.stories.tsx b/packages/docs/src/stories/composite/Modal.stories.tsx index ddeb9f8ed..2f8444846 100644 --- a/packages/docs/src/stories/composite/Modal.stories.tsx +++ b/packages/docs/src/stories/composite/Modal.stories.tsx @@ -70,6 +70,28 @@ export const Default: StoryFn = () => { ) } +/** + * Modal with the `medium` size, 500px wide: between `small` and `large`. + */ +export const Medium: StoryFn = () => { + const [show, setShow] = useState(false) + + const handleClose = () => setShow(false) + const handleShow = () => setShow(true) + + return ( +
+ + + Medium modal + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + +
+ ) +} + /** * Modal that can be dismissed by clicking the backdrop or pressing Esc. */ diff --git a/packages/docs/src/stories/resources/ResourceDetails.stories.tsx b/packages/docs/src/stories/resources/ResourceDetails.stories.tsx index 16acac7b9..b55d1149f 100644 --- a/packages/docs/src/stories/resources/ResourceDetails.stories.tsx +++ b/packages/docs/src/stories/resources/ResourceDetails.stories.tsx @@ -93,13 +93,13 @@ export const InTheSidebar: StoryFn = () => { diff --git a/packages/docs/src/stories/resources/ResourceMetadata.stories.tsx b/packages/docs/src/stories/resources/ResourceMetadata.stories.tsx index 79ae6d1db..7bb733130 100644 --- a/packages/docs/src/stories/resources/ResourceMetadata.stories.tsx +++ b/packages/docs/src/stories/resources/ResourceMetadata.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryFn } from "@storybook/react-vite" -import { useEditMetadataOverlay } from "#hooks/useEditMetadataOverlay" import { CoreSdkProvider } from "#providers/CoreSdkProvider" import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" import { Dropdown, DropdownItem } from "#ui/composite/Dropdown" -import { ResourceMetadata } from "#ui/resources/ResourceMetadata" +import { + ResourceMetadata, + useResourceMetadataModal, +} from "#ui/resources/ResourceMetadata" const setup: Meta = { title: "Resources/ResourceMetadata", @@ -28,7 +30,7 @@ export const Default = Template.bind({}) Default.args = { resourceType: "customers", resourceId: "NMWYhbGorj", - overlay: { + modal: { title: "hello@commercelayer.io", }, } @@ -40,32 +42,43 @@ export const WithoutMetadata = Template.bind({}) WithoutMetadata.args = { resourceType: "customers", resourceId: "OEMAhobdgO", - overlay: { + modal: { title: "hello@commercelayer.io", }, } -/** If you need to edit the metadata from outside the `ResourceMetadata` component you can use the `useEditMetadataOverlay` hook: */ -export const EditMetadataOverlay: StoryFn = () => { - const { Overlay: EditMetadataOverlay, show } = useEditMetadataOverlay() - +/** If you need to edit the metadata from outside the `ResourceMetadata` component you can use the `useResourceMetadataModal` hook: */ +export const EditMetadataModal: StoryFn = () => { return ( - - } - /> + ) } -EditMetadataOverlay.decorators = [ + +/** The hook needs the providers above it, so it lives in a child of the story. */ +const EditMetadataModalTrigger = (): React.JSX.Element => { + const { metadataModal, openMetadataModal } = useResourceMetadataModal({ + title: "hello@commercelayer.io", + resourceId: "ASEYfdNrwa", + resourceType: "customers", + }) + + return ( + <> + {metadataModal} + + } + /> + + ) +} +EditMetadataModal.decorators = [ (Story) => (
= { title: "Resources/ResourceTags", @@ -28,7 +27,7 @@ export const Default = Template.bind({}) Default.args = { resourceType: "customers", resourceId: "NMWYhbGorj", - overlay: { + modal: { title: "hello@commercelayer.io", showManageAction: true, }, @@ -44,32 +43,44 @@ export const WithoutTags = Template.bind({}) WithoutTags.args = { resourceType: "customers", resourceId: "OEMAhobdgO", - overlay: { + modal: { title: "hello@commercelayer.io", }, } -/** If you need to edit the tags from outside the `ResourceTags` component you can use the `useEditTagsOverlay` hook: */ -export const EditTagsOverlay: StoryFn = () => { - const { Overlay: EditTagsOverlay, show } = useEditTagsOverlay() - +/** If you need to edit the tags from outside the `ResourceTags` component you can use the `useResourceTagsModal` hook: */ +export const EditTagsModal: StoryFn = () => { return ( - - } - /> + ) } -EditTagsOverlay.decorators = [ + +/** The hook needs the providers above it, so it lives in a child of the story. */ +const EditTagsModalTrigger = (): React.JSX.Element => { + const { tagsModal, openTagsModal } = useResourceTagsModal({ + title: "hello@commercelayer.io", + showManageAction: true, + resourceId: "ASEYfdNrwa", + resourceType: "customers", + }) + + return ( + <> + {tagsModal} + + } + /> + + ) +} +EditTagsModal.decorators = [ (Story) => (
Date: Tue, 6 Oct 2026 11:58:49 +0200 Subject: [PATCH 2/2] feat: replace overlay behavior of resource update modal used in resource details block --- packages/app-elements/src/main.ts | 5 + .../ResourceDetails/ResourceDetails.test.tsx | 43 +++++- .../ResourceDetails/ResourceDetails.tsx | 11 +- .../ResourceDetails/ResourceDetailsForm.tsx | 77 ----------- .../ResourceReferenceFormFields.tsx | 28 ++++ .../ui/resources/ResourceDetails/index.tsx | 9 ++ .../ResourceDetails/useEditDetailsOverlay.tsx | 59 -------- .../useResourceReferenceForm.tsx | 128 ++++++++++++++++++ .../useResourceReferenceModal.tsx | 88 ++++++++++++ 9 files changed, 307 insertions(+), 141 deletions(-) delete mode 100644 packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetailsForm.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceDetails/ResourceReferenceFormFields.tsx delete mode 100644 packages/app-elements/src/ui/resources/ResourceDetails/useEditDetailsOverlay.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceForm.tsx create mode 100644 packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceModal.tsx diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 256aedbb9..4eb792b98 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -402,6 +402,11 @@ export { export { ResourceDetails, type ResourceDetailsProps, + ResourceReferenceFormFields, + type ResourceReferenceModalProps, + resourceReferenceFormFieldsSchema, + useResourceReferenceForm, + useResourceReferenceModal, } from "#ui/resources/ResourceDetails" export { ResourceDetailsContent, diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.test.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.test.tsx index 395257630..018e466a3 100644 --- a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.test.tsx +++ b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from "@testing-library/react" +import { act, fireEvent, render } from "@testing-library/react" import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" import { PageLayout } from "#ui/composite/PageLayout" import { ResourceDetails } from "./ResourceDetails" @@ -113,6 +113,47 @@ describe("ResourceDetails", () => { expect(container.textContent).toContain("common.edit_resource") }) + it("edits the reference in a modal, starting from the current values", async () => { + const { container, baseElement } = render( + wrap( + {}} + />, + ), + ) + + const dialog = (): HTMLDialogElement | null => + baseElement.querySelector("dialog") + expect(dialog()?.open).toBeFalsy() + + const trigger = container.querySelector('[aria-haspopup="true"]') + assertToBeDefined(trigger) + fireEvent.click(trigger) + await act(async () => { + fireEvent.click( + container.querySelector( + '[aria-label="common.edit_resource"]', + ) as HTMLElement, + ) + }) + + expect(dialog()?.open).toBe(true) + expect( + baseElement.querySelector('input[name="reference"]') + ?.value, + ).toBe("ABC-123") + expect( + baseElement.querySelector( + 'input[name="reference_origin"]', + )?.value, + ).toBe("erp") + }) + // Every difference hides behind `lg:`, so the two read the same on a phone. it("renders identically below lg", () => { const unprefixed = (className: string): string[] => diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.tsx index 211c2d789..da7c4ff3f 100644 --- a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.tsx +++ b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetails.tsx @@ -10,7 +10,7 @@ import { Text } from "#ui/atoms/Text" import { Dropdown, DropdownItem } from "#ui/composite/Dropdown" import { ListDetailsItem } from "#ui/composite/ListDetailsItem" import { useSurfaceVariant } from "#ui/internals/overlayContext" -import { useEditDetailsOverlay } from "./useEditDetailsOverlay" +import { useResourceReferenceModal } from "./useResourceReferenceModal" export interface ResourceDetailsProps { /** @@ -36,7 +36,10 @@ export const ResourceDetails = withSkeletonTemplate( const { user, canUser } = useTokenProvider() const { t, i18n } = useTranslation() const locale = i18n.language as I18NLocale - const { Overlay: EditDetailsOverlay, show } = useEditDetailsOverlay() + const { referenceModal, openReferenceModal } = useResourceReferenceModal({ + resource, + onUpdated, + }) const reference = [resource?.reference, resource?.reference_origin] .filter(Boolean) @@ -76,7 +79,7 @@ export const ResourceDetails = withSkeletonTemplate( resource: t("common.reference").toLowerCase(), })} onClick={() => { - show() + openReferenceModal() }} /> )} @@ -124,7 +127,7 @@ export const ResourceDetails = withSkeletonTemplate( - + {referenceModal} ) }, diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetailsForm.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetailsForm.tsx deleted file mode 100644 index ee3d895ad..000000000 --- a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceDetailsForm.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" -import { getResourceEndpoint } from "#helpers/resources" -import { useCoreSdkProvider } from "#providers/CoreSdkProvider" -import { t } from "#providers/I18NProvider" -import { Button } from "#ui/atoms/Button" -import { withSkeletonTemplate } from "#ui/atoms/SkeletonTemplate" -import { Spacer } from "#ui/atoms/Spacer" -import { HookedForm } from "#ui/forms/Form" -import { HookedInput } from "#ui/forms/Input" -import { HookedValidationApiError } from "#ui/forms/ReactHookForm" -import type { ResourceDetailsProps } from "./ResourceDetails" - -const metadataForm = z.object({ - reference: z.string().nullish(), - reference_origin: z.string().nullish(), -}) - -export const ResourceDetailsForm = withSkeletonTemplate<{ - resource: ResourceDetailsProps["resource"] - onUpdated: ResourceDetailsProps["onUpdated"] -}>(({ resource, onUpdated }) => { - const [apiError, setApiError] = useState(undefined) - const { sdkClient } = useCoreSdkProvider() - - const methods = useForm({ - defaultValues: { - reference: resource.reference, - reference_origin: resource.reference_origin, - }, - resolver: zodResolver(metadataForm), - }) - - return ( - { - void sdkClient[getResourceEndpoint(resource.type)] - .update({ - id: resource.id, - reference: formValues.reference, - reference_origin: formValues.reference_origin, - }) - .then(() => { - void onUpdated() - }) - .catch((error: any) => { - setApiError(error) - }) - }} - > - - - - - - - - - - - - - - ) -}) diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/ResourceReferenceFormFields.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceReferenceFormFields.tsx new file mode 100644 index 000000000..41875e9eb --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceDetails/ResourceReferenceFormFields.tsx @@ -0,0 +1,28 @@ +import { z } from "zod" +import { t } from "#providers/I18NProvider" +import { withSkeletonTemplate } from "#ui/atoms/SkeletonTemplate" +import { HookedInput } from "#ui/forms/Input" + +export const resourceReferenceFormFieldsSchema = z.object({ + reference: z.string().nullish(), + reference_origin: z.string().nullish(), +}) + +/** + * The `reference` and `reference_origin` inputs of a resource. Reads the form + * through context, so it can sit anywhere inside the `FormProvider` set up by + * `useResourceReferenceForm`. + */ +export const ResourceReferenceFormFields = withSkeletonTemplate(() => { + return ( +
+ + +
+ ) +}) + +ResourceReferenceFormFields.displayName = "ResourceReferenceFormFields" diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/index.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/index.tsx index a96ad8961..448710c17 100644 --- a/packages/app-elements/src/ui/resources/ResourceDetails/index.tsx +++ b/packages/app-elements/src/ui/resources/ResourceDetails/index.tsx @@ -1 +1,10 @@ export { ResourceDetails, type ResourceDetailsProps } from "./ResourceDetails" +export { + ResourceReferenceFormFields, + resourceReferenceFormFieldsSchema, +} from "./ResourceReferenceFormFields" +export { useResourceReferenceForm } from "./useResourceReferenceForm" +export { + type ResourceReferenceModalProps, + useResourceReferenceModal, +} from "./useResourceReferenceModal" diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/useEditDetailsOverlay.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/useEditDetailsOverlay.tsx deleted file mode 100644 index aaac7f3e1..000000000 --- a/packages/app-elements/src/ui/resources/ResourceDetails/useEditDetailsOverlay.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { type FC, useCallback } from "react" -import { useOverlay } from "#hooks/useOverlay" -import { t } from "#providers/I18NProvider" -import { PageLayout } from "#ui/composite/PageLayout" -import type { ResourceDetailsProps } from "./ResourceDetails" -import { ResourceDetailsForm } from "./ResourceDetailsForm" - -export interface EditDetailsOverlayProps { - /** - * Optional title shown as first line in edit overlay heading - */ - title?: string - resource: ResourceDetailsProps["resource"] - onUpdated: ResourceDetailsProps["onUpdated"] -} - -interface DetailsOverlayHook { - show: () => void - Overlay: FC -} - -export function useEditDetailsOverlay(): DetailsOverlayHook { - const { Overlay: OverlayElement, open, close } = useOverlay() - - const OverlayComponent = useCallback>( - ({ title = t("common.back"), resource, onUpdated }) => { - return ( - - { - close() - }, - }} - > - { - await onUpdated().then(() => { - close() - }) - }} - /> - - - ) - }, - [OverlayElement], - ) - - return { - show: open, - Overlay: OverlayComponent, - } -} diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceForm.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceForm.tsx new file mode 100644 index 000000000..d86a3dfcd --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceForm.tsx @@ -0,0 +1,128 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { type ReactNode, useEffect, useState } from "react" +import { type UseFormReturn, useForm } from "react-hook-form" +import { getResourceEndpoint } from "#helpers/resources" +import { useCoreSdkProvider } from "#providers/CoreSdkProvider" +import { t } from "#providers/I18NProvider" +import { Button } from "#ui/atoms/Button" +import { HookedValidationApiError } from "#ui/forms/ReactHookForm" +import type { ResourceDetailsProps } from "./ResourceDetails" +import { + ResourceReferenceFormFields, + resourceReferenceFormFieldsSchema, +} from "./ResourceReferenceFormFields" + +export interface UseResourceReferenceFormProps { + resource: ResourceDetailsProps["resource"] + /** + * Whether the form is currently visible. The form starts over whenever this + * flips to `true`, so a container that keeps it mounted across openings still + * shows the reference as it is now rather than what was last typed. + */ + isOpen?: boolean + /** Called once the resource has been updated */ + onSubmitted?: () => void | Promise + /** + * Called when the user gives up on the edit. Leaving it out drops the cancel + * control, for a container that has a way out of its own. + */ + onCancel?: () => void +} + +interface ResourceReferenceFormHook { + /** `react-hook-form` context, to be spread on a `FormProvider` around both slots */ + methods: UseFormReturn + /** Submit handler for the `` hosting the fields (e.g. `Modal`'s `onSubmit`) */ + onSubmit: React.FormEventHandler + /** The reference inputs, to be rendered in the scrollable area */ + fields: ReactNode + /** + * The controls that stay put while the fields scroll — cancel, submit — plus + * the API error. To be rendered in the pinned area. + */ + footer: ReactNode +} + +/** + * Builds the pieces of a `reference` / `reference_origin` update form, leaving + * their placement to the caller: `fields` and `footer` can go in separate + * containers — such as a `Modal`'s `Body` and `Footer` — as long as a + * `FormProvider` spread with `methods` wraps both and the enclosing `` is + * given `onSubmit`. + */ +export function useResourceReferenceForm({ + resource, + isOpen = true, + onSubmitted, + onCancel, +}: UseResourceReferenceFormProps): ResourceReferenceFormHook { + const [apiError, setApiError] = useState(undefined) + const { sdkClient } = useCoreSdkProvider() + + const toFormValues = (): { + reference: string | null | undefined + reference_origin: string | null | undefined + } => ({ + reference: resource.reference, + reference_origin: resource.reference_origin, + }) + + const methods = useForm({ + defaultValues: toFormValues(), + resolver: zodResolver(resourceReferenceFormFieldsSchema), + }) + + useEffect(() => { + if (isOpen) { + methods.reset(toFormValues()) + setApiError(undefined) + } + }, [isOpen]) + + const handleSubmit = methods.handleSubmit(async (formValues) => { + await sdkClient[getResourceEndpoint(resource.type)] + .update({ + id: resource.id, + reference: formValues.reference, + reference_origin: formValues.reference_origin, + }) + .then(async () => { + await onSubmitted?.() + }) + .catch((error: any) => { + setApiError(error) + }) + }) + + return { + methods, + onSubmit: (event) => { + void handleSubmit(event) + }, + fields: , + footer: ( + <> +
+ {onCancel != null && ( + + )} + +
+ + + ), + } +} diff --git a/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceModal.tsx b/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceModal.tsx new file mode 100644 index 000000000..451f87a5c --- /dev/null +++ b/packages/app-elements/src/ui/resources/ResourceDetails/useResourceReferenceModal.tsx @@ -0,0 +1,88 @@ +import type { ListableResourceType } from "@commercelayer/sdk" +import { type ReactNode, useCallback, useState } from "react" +import { FormProvider } from "react-hook-form" +import { t } from "#providers/I18NProvider" +import { useTokenProvider } from "#providers/TokenProvider" +import { Modal } from "#ui/composite/Modal" +import type { ResourceDetailsProps } from "./ResourceDetails" +import { useResourceReferenceForm } from "./useResourceReferenceForm" + +export interface ResourceReferenceModalProps { + /** + * Optional heading for the modal, replacing the default `Edit reference` + */ + title?: string + resource: ResourceDetailsProps["resource"] + onUpdated: ResourceDetailsProps["onUpdated"] +} + +interface ResourceReferenceModalHook { + /** The modal itself — render it as is, its visibility is driven by the hook */ + referenceModal: ReactNode + /** Opens the modal, when the user is allowed to */ + openReferenceModal: () => void +} + +export function useResourceReferenceModal({ + title, + resource, + onUpdated, +}: ResourceReferenceModalProps): ResourceReferenceModalHook { + const { canUser } = useTokenProvider() + const [isOpen, setIsOpen] = useState(false) + + const canEditReference = canUser( + "update", + resource.type as ListableResourceType, + ) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const openReferenceModal = useCallback(() => { + if (canEditReference) { + setIsOpen(true) + } + }, [canEditReference]) + + const { methods, onSubmit, fields, footer } = useResourceReferenceForm({ + resource, + isOpen, + onSubmitted: async () => { + await onUpdated() + close() + }, + onCancel: close, + }) + + return { + // an element rather than a component: React then reconciles it by type on + // every render, so the `Modal` is updated in place instead of being + // remounted — a remount would skip the cleanup that releases the body + // scroll lock, leaving the page stuck + referenceModal: canEditReference ? ( + // no DOM of its own, so it can span the modal's slots and let the submit + // button in the footer drive the fields in the body + + + + {title ?? + t("common.edit_resource", { + resource: t("common.reference").toLowerCase(), + })} + + {fields} + {footer} + + + ) : null, + openReferenceModal, + } +}