diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fccf6da..4197c7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,12 @@ jobs: - name: Build Storybook run: yarn build-storybook + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Storybook tests + run: yarn workspace @lambdacurry/medusa-forms-docs test + - name: Lint and format check run: yarn format-and-lint @@ -47,4 +53,3 @@ jobs: name: test-failure path: apps/docs/storybook-static retention-days: 2 - diff --git a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx index 36f027d..d3aa342 100644 --- a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx @@ -1,6 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ControlledCurrencyInput } from '@lambdacurry/medusa-forms/controlled/ControlledCurrencyInput'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -21,7 +23,7 @@ export default meta; type Story = StoryObj; interface CurrencyFormData { - price: string; + price: string | number; } // Base wrapper component for stories @@ -61,6 +63,84 @@ const CurrencyInputWithHookForm = ({ ); }; +const CurrencyInputWithStringState = () => { + const form = useForm({ + defaultValues: { price: '' }, + }); + const price = form.watch('price'); + + return ( + +
+ name="price" label="String price" symbol="$" code="usd" /> +
+          {JSON.stringify({ value: price, type: typeof price }, null, 2)}
+        
+
+
+ ); +}; + +const CurrencyInputWithRequiredError = () => { + const form = useForm({ + defaultValues: { price: '' }, + mode: 'onChange', + }); + + useEffect(() => { + form.trigger('price').then(() => undefined); + }, [form]); + + return ( + +
+ +
+
+ ); +}; + +const CurrencyInputWithValueAsNumber = () => { + const form = useForm({ + defaultValues: { price: '' }, + }); + const price = form.watch('price'); + + return ( + +
+ + name="price" + label="Numeric price" + symbol="$" + code="usd" + rules={{ valueAsNumber: true }} + /> +
+          {JSON.stringify({ value: price, type: typeof price }, null, 2)}
+        
+
+
+ ); +}; + +const getStateOutput = (canvas: ReturnType) => canvas.getByText((content) => content.includes('"type"')); +const getInputByName = (canvasElement: HTMLElement, name: string) => { + const input = canvasElement.querySelector(`input[name="${name}"]`); + + if (!input) { + throw new Error(`Input with name "${name}" was not found.`); + } + + return input; +}; + // 1. Different Currency Symbols export const USDCurrency: Story = { args: { @@ -194,6 +274,74 @@ export const RequiredFieldValidation: Story = { ), }; +export const RequiredRuleError: Story = { + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + expect(canvas.getByText('Price is required')).toBeInTheDocument(); + }); + }, +}; + +export const DefaultStringValue: Story = { + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.type(input, '1234'); + + await waitFor(() => { + expect(input.value).toContain('1,234'); + expect(state).toHaveTextContent('"value": "1234"'); + expect(state).toHaveTextContent('"type": "string"'); + }); + }, +}; + +export const ValueAsNumber: Story = { + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.type(input, '1234'); + + await waitFor(() => { + expect(input.value).toContain('1,234'); + expect(state).toHaveTextContent('"value": 1234'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.clear(input); + + await waitFor(() => { + expect(input.value).toBe(''); + expect(state).toHaveTextContent('"value": null'); + expect(state).toHaveTextContent('"type": "number"'); + }); + }, +}; + const customValidationSchema = z.object({ price: z.string().refine((val) => { const num = Number.parseFloat(val); diff --git a/apps/docs/src/medusa-forms/ControlledInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledInput.stories.tsx index 66d163e..073030c 100644 --- a/apps/docs/src/medusa-forms/ControlledInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledInput.stories.tsx @@ -1,5 +1,6 @@ import { ControlledInput } from '@lambdacurry/medusa-forms/controlled/ControlledInput'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { FormProvider, useForm } from 'react-hook-form'; const meta = { @@ -14,10 +15,20 @@ const meta = { export default meta; type Story = StoryObj; +interface InputFormData { + username: string; + quantity: number | string; + optionalAmount: number | null | string; + startDate: Date | string; +} + const ControlledInputWithHookForm = () => { - const form = useForm({ + const form = useForm({ defaultValues: { username: '', + quantity: '', + optionalAmount: '', + startDate: '', }, }); @@ -30,6 +41,98 @@ const ControlledInputWithHookForm = () => { ); }; +const NumberInputWithHookForm = () => { + const form = useForm({ + defaultValues: { + username: '', + quantity: '', + optionalAmount: '', + startDate: '', + }, + }); + const quantity = form.watch('quantity'); + + return ( + +
+ + name="quantity" + type="number" + label="Quantity" + rules={{ valueAsNumber: true }} + /> +
+          {JSON.stringify({ value: quantity, type: typeof quantity }, null, 2)}
+        
+
+
+ ); +}; + +const NullableNumberInputWithHookForm = () => { + const form = useForm({ + defaultValues: { + username: '', + quantity: '', + optionalAmount: '', + startDate: '', + }, + }); + const optionalAmount = form.watch('optionalAmount'); + + return ( + +
+ + name="optionalAmount" + type="number" + label="Optional amount" + rules={{ + setValueAs: (value) => (value === '' ? null : Number(value)), + }} + /> +
+          {JSON.stringify({ value: optionalAmount, type: typeof optionalAmount }, null, 2)}
+        
+
+
+ ); +}; + +const DateInputWithHookForm = () => { + const form = useForm({ + defaultValues: { + username: '', + quantity: '', + optionalAmount: '', + startDate: '', + }, + }); + const startDate = form.watch('startDate'); + + return ( + +
+ name="startDate" type="date" label="Start date" rules={{ valueAsDate: true }} /> +
+          {JSON.stringify({ value: startDate, type: typeof startDate }, null, 2)}
+        
+
+
+ ); +}; + +const getStateOutput = (canvas: ReturnType) => canvas.getByText((content) => content.includes('"type"')); +const getInputByName = (canvasElement: HTMLElement, name: string) => { + const input = canvasElement.querySelector(`input[name="${name}"]`); + + if (!input) { + throw new Error(`Input with name "${name}" was not found.`); + } + + return input; +}; + export const WithReactHookForm: Story = { args: { name: 'username', @@ -37,4 +140,86 @@ export const WithReactHookForm: Story = { placeholder: 'Enter your username', }, render: () => , + play: async ({ canvasElement }) => { + const input = getInputByName(canvasElement, 'username'); + + await userEvent.type(input, 'validuser'); + + expect(input).toHaveValue('validuser'); + }, +}; + +export const ValueAsNumber: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'quantity'); + const state = getStateOutput(canvas); + + await userEvent.type(input, '42'); + + await waitFor(() => { + expect(input).toHaveValue(42); + expect(state).toHaveTextContent('"value": 42'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.clear(input); + + await waitFor(() => { + expect(input).toHaveValue(null); + expect(state).toHaveTextContent('"value": null'); + expect(state).toHaveTextContent('"type": "number"'); + }); + }, +}; + +export const SetValueAs: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'optionalAmount'); + const state = getStateOutput(canvas); + + await userEvent.type(input, '12'); + + await waitFor(() => { + expect(input).toHaveValue(12); + expect(state).toHaveTextContent('"value": 12'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.clear(input); + + await waitFor(() => { + expect(input).toHaveValue(null); + expect(state).toHaveTextContent('"value": null'); + expect(state).toHaveTextContent('"type": "object"'); + }); + }, +}; + +export const ValueAsDate: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'startDate'); + const state = getStateOutput(canvas); + + await userEvent.type(input, '2026-06-24'); + + await waitFor(() => { + expect(input).toHaveValue('2026-06-24'); + expect(state).toHaveTextContent('"value": "2026-06-24T'); + expect(state).toHaveTextContent('"type": "object"'); + }); + + await userEvent.clear(input); + + await waitFor(() => { + expect(input).toHaveValue(''); + expect(state).toHaveTextContent('"value": null'); + expect(state).toHaveTextContent('"type": "object"'); + }); + }, }; diff --git a/packages/medusa-forms/package.json b/packages/medusa-forms/package.json index 0a08a81..271537f 100644 --- a/packages/medusa-forms/package.json +++ b/packages/medusa-forms/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/medusa-forms", - "version": "0.2.8", + "version": "0.3.0", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 5131744..9607a5a 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -1,38 +1,45 @@ import type * as React from 'react'; -import { - Controller, - type ControllerProps, - type FieldValues, - type Path, - type RegisterOptions, - useFormContext, -} from 'react-hook-form'; +import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form'; import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput'; +import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms'; export type ControlledCurrencyInputProps = CurrencyInputProps & - Omit & { + Omit, 'render' | 'control' | 'rules'> & { name: Path; + rules?: ControlledRules; }; export const ControlledCurrencyInput = ({ name, rules, + onChange, ...props }: ControlledCurrencyInputProps) => { - const { control } = useFormContext(); + const { + control, + formState: { errors }, + } = useFormContext(); + const { controllerRules, hasTransform } = splitTransformRules(rules); return ( control={control} name={name} - rules={rules as Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + rules={controllerRules} render={({ field }) => { return ( ) => { - field.onChange(e.target.value.replace(/[^0-9.-]+/g, '')); + if (onChange) { + onChange(e); + } + + const value = e.target.value.replace(/[^0-9.-]+/g, ''); + field.onChange(hasTransform ? transformValue(value, rules) : value); }} /> ); diff --git a/packages/medusa-forms/src/controlled/ControlledInput.tsx b/packages/medusa-forms/src/controlled/ControlledInput.tsx index 6cd6024..e8f81c3 100644 --- a/packages/medusa-forms/src/controlled/ControlledInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledInput.tsx @@ -1,20 +1,16 @@ -import type { ComponentProps } from 'react'; -import { - Controller, - type ControllerProps, - type FieldValues, - type Path, - type RegisterOptions, - useFormContext, -} from 'react-hook-form'; +import type { ChangeEvent, ComponentProps } from 'react'; +import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form'; import { Input, type InputProps } from '../ui/Input'; +import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms'; export type ControlledInputProps = InputProps & - Omit & { + Omit, 'render' | 'rules'> & { name: Path; - rules?: Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; + rules?: ControlledRules; } & ComponentProps & - Omit, 'render'>; + Omit, 'render' | 'rules'>; + +const getEventValue = (evt: ChangeEvent) => evt.target.value; export const ControlledInput = ({ name, @@ -26,23 +22,25 @@ export const ControlledInput = ({ control, formState: { errors }, } = useFormContext(); + const { controllerRules, hasTransform } = splitTransformRules(rules); return ( >, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + rules={controllerRules} render={({ field }) => ( { if (onChange) { onChange(evt); } - field.onChange(evt); + field.onChange(hasTransform ? transformValue(getEventValue(evt), rules) : evt); }} /> )} diff --git a/packages/medusa-forms/src/controlled/valueTransforms.ts b/packages/medusa-forms/src/controlled/valueTransforms.ts new file mode 100644 index 0000000..345cdb5 --- /dev/null +++ b/packages/medusa-forms/src/controlled/valueTransforms.ts @@ -0,0 +1,57 @@ +import type { FieldValues, Path, RegisterOptions } from 'react-hook-form'; + +export type ControlledRules = Omit>, 'disabled'>; +type ControllerRules = Omit, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; + +export const splitTransformRules = ( + rules: ControlledRules | undefined, +): { controllerRules: ControllerRules; hasTransform: boolean } => { + const { valueAsNumber, valueAsDate, setValueAs, ...controllerRules } = rules ?? {}; + + return { + controllerRules, + hasTransform: Boolean(valueAsNumber || valueAsDate || setValueAs), + }; +}; + +export const transformValue = (value: string, rules: ControlledRules | undefined) => { + if (rules?.valueAsNumber) { + return value === '' ? Number.NaN : +value; + } + + if (rules?.valueAsDate) { + return new Date(value); + } + + if (typeof rules?.setValueAs === 'function') { + return rules.setValueAs(value); + } + + return value; +}; + +const isInvalidDate = (value: Date) => Number.isNaN(value.getTime()); + +export const serializeDisplayValue = (value: unknown, rules: ControlledRules | undefined) => { + if (value == null) { + return ''; + } + + if (rules?.valueAsNumber) { + return typeof value === 'number' && Number.isNaN(value) ? '' : String(value); + } + + if (rules?.valueAsDate) { + if (!(value instanceof Date) || isInvalidDate(value)) { + return ''; + } + + return value.toISOString().slice(0, 10); + } + + if (Array.isArray(value)) { + return value.map((item) => String(item)); + } + + return String(value); +};