Create Product Block
CreateProduct is a controlled product creation form built on MUI.
Installation
- npm
- yarn
- pnpm
- bun
npm install @nodeblocks/frontend-create-product-block
yarn add @nodeblocks/frontend-create-product-block
pnpm add @nodeblocks/frontend-create-product-block
bun add @nodeblocks/frontend-create-product-block
What You Need
| Item | Why it matters |
|---|---|
data | Single source of truth for form state; includes fields like title, categoryId, quantity, tags, prefecture, and image |
onDataChange | Receives updated state plus metadata for validation, analytics, or side effects |
errors (optional) | Field errors keyed by bracket-notation paths |
labels (optional) | Root copy for section titles, field labels, button text, dropzone messages, and unit suffixes |
placeholders (optional) | Root placeholder copy for text, number, and select fields |
selectOptions (optional) | Dropdown options for category, type, option, and prefecture fields |
tagTypes and tags (optional) | Enables the tags section in BasicInfo.TagsField |
onRejectAttachment (optional) | Called when the image dropzone rejects a file |
CreateProduct does not own form state. Keep state in your app and pass it through data. The default shape includes title, categoryId, typeId, optionId, quantity, description, details, tags, prefecture, city, onlineAvailability, inventoryNotes, availableFrom, availableUntil, additionalDetails, and image.
Code Examples
- Quick Start
- Labels and Copy
- Form Errors
- Compound Components
- Block Override
function Example() { const defaultData = { title: '', categoryId: '', typeId: '', optionId: '', quantity: '', description: '', details: '', tags: [], prefecture: '', city: '', onlineAvailability: false, inventoryNotes: '', availableFrom: '', availableUntil: '', additionalDetails: '', image: null, }; const [data, setData] = React.useState(defaultData); const prefectureOptions = [ { value: 'Hokkaido', label: 'Hokkaido' }, { value: 'Tokyo', label: 'Tokyo' }, { value: 'Osaka', label: 'Osaka' }, { value: 'Okinawa', label: 'Okinawa' }, ]; return ( <CreateProduct data={data} onDataChange={setData} selectOptions={{ categoryOptions: [ { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, ], typeOptions: [ { value: 'physical', label: 'Physical' }, { value: 'digital', label: 'Digital' }, ], optionOptions: [ { value: 'standard', label: 'Standard' }, { value: 'premium', label: 'Premium' }, ], prefectureOptions, }} tagTypes={[ { id: '100', label: 'Highlights' }, { id: '200', label: 'Shipping' }, ]} tags={[ { id: '101', typeId: '100', label: 'Best seller' }, { id: '201', typeId: '200', label: 'Ships from Japan' }, ]} onSubmit={(event) => { event.preventDefault(); setData((current) => ({ ...current })); }} /> ); }
function Example() { const defaultData = { title: '', categoryId: '', typeId: '', optionId: '', quantity: '', description: '', details: '', tags: [], prefecture: '', city: '', onlineAvailability: false, inventoryNotes: '', availableFrom: '', availableUntil: '', additionalDetails: '', image: null, }; const [data, setData] = React.useState(defaultData); const prefectureOptions = [ { value: 'Hokkaido', label: 'Hokkaido' }, { value: 'Tokyo', label: 'Tokyo' }, { value: 'Osaka', label: 'Osaka' }, { value: 'Okinawa', label: 'Okinawa' }, ]; return ( <CreateProduct data={data} onDataChange={setData} labels={{ titleField: 'Product title', dropzoneUploadImage: 'Upload a product image', dropzoneSubtitle: 'PNG or JPG, up to 2MB', dropzoneOptionsButton: 'Image options', dropzoneReplaceFile: 'Replace image', dropzoneDeleteFile: 'Remove image', basicInfoSectionTitle: 'Details', categoryField: 'Category', typeField: 'Type', optionField: 'Option', quantityField: 'Quantity', quantityUnit: 'pcs', descriptionField: 'Description', detailsField: 'Details', tagsField: 'Tags', additionalInfoTitle: 'Inventory', additionalInfoSubtitle: 'Location', prefectureField: 'Prefecture', cityField: 'City', onlineAvailabilityField: 'Available online', inventoryNotesField: 'Inventory notes', availableFromField: 'Available from', availableUntilField: 'Available until', additionalDetailsField: 'Additional details', submitButton: 'Create product', }} placeholders={{ titleField: 'Enter a product title', categoryField: 'Select a category', typeField: 'Select a type', optionField: 'Select an option', quantityField: 'Enter quantity', descriptionField: 'Describe the product', detailsField: 'Enter details', prefectureField: 'Select prefecture', cityField: 'Enter city', inventoryNotesField: 'Notes about inventory', additionalDetailsField: 'Enter additional details', }} selectOptions={{ categoryOptions: [ { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, ], typeOptions: [ { value: 'physical', label: 'Physical' }, { value: 'digital', label: 'Digital' }, ], optionOptions: [ { value: 'standard', label: 'Standard' }, { value: 'premium', label: 'Premium' }, ], prefectureOptions, }} onSubmit={(event) => { event.preventDefault(); setData((current) => ({ ...current })); }} /> ); }
Pass section and field copy on the root when the same wording applies throughout the form. Override the image dropzone text, field labels, or submit label on the nested subcomponents only when you need layout-specific wording.
function Example() { const defaultData = { title: '', categoryId: '', typeId: '', optionId: '', quantity: '', description: '', details: '', tags: [], prefecture: '', city: '', onlineAvailability: false, inventoryNotes: '', availableFrom: '', availableUntil: '', additionalDetails: '', image: null, }; const [data, setData] = React.useState(defaultData); const prefectureOptions = [ { value: 'Hokkaido', label: 'Hokkaido' }, { value: 'Tokyo', label: 'Tokyo' }, { value: 'Osaka', label: 'Osaka' }, { value: 'Okinawa', label: 'Okinawa' }, ]; const [errors, setErrors] = React.useState({}); const handleDataChange = (nextData, meta) => { const { [meta.name]: _removed, ...restErrors } = errors; const nextErrors = { ...restErrors }; // Validate required fields on blur (same pattern as storybook) if (meta.cause === 'blur') { const value = nextData[meta.name]; if (value === '' || value == null || (Array.isArray(value) && value.length === 0)) { nextErrors[meta.name] = 'This field is required.'; } } setErrors(nextErrors); setData(nextData); }; return ( <CreateProduct data={data} errors={Object.keys(errors).length ? errors : undefined} onDataChange={handleDataChange} selectOptions={{ categoryOptions: [ { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, ], typeOptions: [ { value: 'physical', label: 'Physical' }, { value: 'digital', label: 'Digital' }, ], optionOptions: [ { value: 'standard', label: 'Standard' }, { value: 'premium', label: 'Premium' }, ], prefectureOptions, }} tagTypes={[ { id: '100', label: 'Highlights' }, { id: '200', label: 'Shipping' }, ]} tags={[ { id: '101', typeId: '100', label: 'Best seller' }, { id: '201', typeId: '200', label: 'Ships from Japan' }, ]} onSubmit={(event) => { event.preventDefault(); setData((current) => ({ ...current })); }} /> ); }
function Example() { const defaultData = { title: '', categoryId: '', typeId: '', optionId: '', quantity: '', description: '', details: '', tags: [], prefecture: '', city: '', onlineAvailability: false, inventoryNotes: '', availableFrom: '', availableUntil: '', additionalDetails: '', image: null, }; const [data, setData] = React.useState(defaultData); const prefectureOptions = [ { value: 'Hokkaido', label: 'Hokkaido' }, { value: 'Tokyo', label: 'Tokyo' }, { value: 'Osaka', label: 'Osaka' }, { value: 'Okinawa', label: 'Okinawa' }, ]; return ( <CreateProduct data={data} onDataChange={setData} selectOptions={{ categoryOptions: [ { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, ], typeOptions: [ { value: 'physical', label: 'Physical' }, { value: 'digital', label: 'Digital' }, ], optionOptions: [ { value: 'standard', label: 'Standard' }, { value: 'premium', label: 'Premium' }, ], prefectureOptions, }} tagTypes={[ { id: '100', label: 'Highlights' }, { id: '200', label: 'Shipping' }, ]} tags={[ { id: '101', typeId: '100', label: 'Best seller' }, { id: '201', typeId: '200', label: 'Ships from Japan' }, ]} onSubmit={(event) => { event.preventDefault(); setData((current) => ({ ...current })); }} > <CreateProduct.MainInfo> <CreateProduct.MainInfo.Dropzone /> <CreateProduct.MainInfo.TitleField /> </CreateProduct.MainInfo> <CreateProduct.BasicInfo> <CreateProduct.BasicInfo.SectionTitle /> <CreateProduct.BasicInfo.CategoryField /> <CreateProduct.BasicInfo.TypeField /> <CreateProduct.BasicInfo.OptionField /> <CreateProduct.BasicInfo.QuantityField /> <CreateProduct.BasicInfo.DescriptionField /> <CreateProduct.BasicInfo.DetailsField /> <CreateProduct.BasicInfo.TagsField /> </CreateProduct.BasicInfo> <CreateProduct.AdditionalInfo> <CreateProduct.AdditionalInfo.Title /> <CreateProduct.AdditionalInfo.Subtitle /> <CreateProduct.AdditionalInfo.PrefectureField /> <CreateProduct.AdditionalInfo.CityField /> <CreateProduct.AdditionalInfo.OnlineAvailabilityField /> <CreateProduct.AdditionalInfo.InventoryNotesField /> <CreateProduct.AdditionalInfo.AdditionalDetailsField /> </CreateProduct.AdditionalInfo> <CreateProduct.Actions> <CreateProduct.Actions.SubmitButton>Create Product</CreateProduct.Actions.SubmitButton> </CreateProduct.Actions> </CreateProduct> ); }
function Example() { const defaultData = { title: '', categoryId: '', typeId: '', optionId: '', quantity: '', description: '', details: '', tags: [], prefecture: '', city: '', onlineAvailability: false, inventoryNotes: '', availableFrom: '', availableUntil: '', additionalDetails: '', image: null, }; const [data, setData] = React.useState(defaultData); const prefectureOptions = [ { value: 'Hokkaido', label: 'Hokkaido' }, { value: 'Tokyo', label: 'Tokyo' }, { value: 'Osaka', label: 'Osaka' }, { value: 'Okinawa', label: 'Okinawa' }, ]; return ( <CreateProduct data={data} onDataChange={setData} selectOptions={{ categoryOptions: [ { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, ], typeOptions: [ { value: 'physical', label: 'Physical' }, { value: 'digital', label: 'Digital' }, ], optionOptions: [ { value: 'standard', label: 'Standard' }, { value: 'premium', label: 'Premium' }, ], prefectureOptions, }} tagTypes={[ { id: '100', label: 'Highlights' }, { id: '200', label: 'Shipping' }, ]} tags={[ { id: '101', typeId: '100', label: 'Best seller' }, { id: '201', typeId: '200', label: 'Ships from Japan' }, ]} onSubmit={(event) => { event.preventDefault(); setData((current) => ({ ...current })); }} > {({ defaultBlocks, defaultBlockOrder }) => ({ blocks: { ...defaultBlocks, customNotification: ( <div style={{ width: '100%', border: '1px solid #cddcff', borderRadius: 8, padding: 12, background: '#eef4ff', fontSize: 14, }} > Custom notification block added via block override </div> ), }, blockOrder: ['customNotification', ...defaultBlockOrder], })} </CreateProduct> ); }
When to use block overrides
Use overrides when you need to change order, replace default UI blocks, or inject custom content while preserving shared state handling. The default render order is mainInfo, basicInfo, additionalInfo, actions, followed by the stripped input primitives textField, numberField, timeField, checkboxField, and selectField for custom flows.
Important Props
Core Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
data | CreateProductFormData ({ title, categoryId, typeId, optionId, quantity, description, details, tags, prefecture, city, onlineAvailability, inventoryNotes, availableFrom, availableUntil, additionalDetails, image } or extended Record<string, unknown>) | Yes | - | Controlled form data object |
onDataChange | (data, meta) => void | Yes | - | Called on updates; meta includes name, value, cause (input, change, blur, clear, reset, programmatic), and optional event |
errors | { [fieldName: string]: string | string[] } | No | undefined | Field errors keyed by bracket-notation paths such as title, categoryId, image, or nested custom paths |
labels | { mainInfoSectionTitle?, titleField?, dropzoneDropHere?, dropzoneUploadImage?, dropzoneSubtitle?, dropzoneImageAlt?, dropzoneOptionsButton?, dropzoneReplaceFile?, dropzoneDeleteFile?, basicInfoSectionTitle?, categoryField?, typeField?, optionField?, quantityField?, quantityUnit?, descriptionField?, detailsField?, tagsField?, additionalInfoTitle?, additionalInfoSubtitle?, prefectureField?, cityField?, onlineAvailabilityField?, inventoryNotesField?, availableFromField?, availableUntilField?, additionalDetailsField?, submitButton? } | No | mainInfoSectionTitle: 'Create a Product', titleField: 'Title', dropzoneDropHere: 'Drop the image here...', dropzoneUploadImage: 'Upload product image', dropzoneSubtitle: 'PNG, JPG up to 2MB', dropzoneImageAlt: 'Selected', dropzoneOptionsButton: 'Options', dropzoneReplaceFile: 'Select a new file', dropzoneDeleteFile: 'Delete', basicInfoSectionTitle: 'Basic Information', categoryField: 'Category', typeField: 'Type', optionField: 'Option', quantityField: 'Quantity', quantityUnit: 'pcs', descriptionField: 'Description', detailsField: 'Details', tagsField: 'Tags', additionalInfoTitle: 'Inventory', additionalInfoSubtitle: 'Location', prefectureField: 'Prefecture', cityField: 'City', onlineAvailabilityField: 'Online Availability', inventoryNotesField: 'Inventory Notes', availableFromField: 'Available From', availableUntilField: 'Available Until', additionalDetailsField: 'Additional Details', submitButton: 'Submit' | Root copy for the title, sections, field labels, dropzone text, and submit button |
placeholders | { titleField?, categoryField?, typeField?, optionField?, quantityField?, descriptionField?, detailsField?, prefectureField?, cityField?, inventoryNotesField?, additionalDetailsField? } | No | titleField: 'Enter title', categoryField: 'Select category', typeField: 'Select type', optionField: 'Select option', quantityField: 'Enter quantity', descriptionField: 'Describe the product', detailsField: 'Enter details', prefectureField: 'Enter prefecture', cityField: 'Enter city', inventoryNotesField: 'Notes about inventory', additionalDetailsField: 'Enter additional details' | Root placeholder copy for text, number, and select fields |
selectOptions | { categoryOptions?, typeOptions?, optionOptions?, prefectureOptions? } | No | undefined | Dropdown option sets for the select fields; omitted groups render as empty lists |
tagTypes | TagType[] | No | undefined | Enables grouped rendering in BasicInfo.TagsField |
tags | Tag[] | No | undefined | Available tags rendered in BasicInfo.TagsField |
onRejectAttachment | (file: File, error: DropzoneFileError) => void | No | undefined | Called when the dropzone rejects an image file |
Content Props
No additional root-only content props are exposed beyond labels, placeholders, selectOptions, tagTypes, and tags.
Layout and Composition Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
component | StackProps<'form'>['component'] | No | 'form' | Root container element |
children | ReactNode or block override function | No | undefined | Pass JSX children for compound components, or a function to override blocks |
className | string | No | undefined | Additional class name on the root container |
sx | StackProps<'form'>['sx'] | No | undefined | MUI sx styling for the root container |
Inherits StackProps<'form'> props except children. The root renders mainInfo, basicInfo, additionalInfo, and actions by default. The internal input primitives are exported separately for custom layouts: TextField, NumberField, TimeField, CheckboxField, and SelectField.
Default UI Blocks
| Block | MUI base component(s) | Notes |
|---|---|---|
CreateProduct | Stack + LocalizationProvider | Root provider and layout container; defaults to component="form" with centered content and a fixed max width |
MainInfo | Stack | Top section for image upload and title |
MainInfo.SectionTitle | Typography | Default copy is Create a Product |
MainInfo.Dropzone | Box + Button + Menu + Typography | name="image", default drop message Drop the image here..., upload prompt Upload product image, subtitle PNG, JPG up to 2MB, options button Options |
MainInfo.TitleField | TextField | name="title", default label Title, default placeholder Enter title, max length 100 |
BasicInfo | Stack | Middle section for catalog, quantity, description, details, and tags |
BasicInfo.SectionTitle | Typography | Default copy is Basic Information |
BasicInfo.CategoryField | SelectField | name="categoryId", default label Category, default placeholder Select category |
BasicInfo.TypeField | SelectField | name="typeId", default label Type, default placeholder Select type |
BasicInfo.OptionField | SelectField | name="optionId", default label Option, default placeholder Select option |
BasicInfo.QuantityField | FormControl + FormLabel + OutlinedInput | name="quantity", default label Quantity, default placeholder Enter quantity, unit suffix pcs |
BasicInfo.DescriptionField | TextField | name="description", default label Description, default placeholder Describe the product, multiline |
BasicInfo.DetailsField | TextField | name="details", default label Details, default placeholder Enter details, multiline |
BasicInfo.TagsField | FormControl + FormLabel + FormGroup + Checkbox | name="tags", default label Tags; rendered only when both tagTypes and tags are provided |
AdditionalInfo | Stack | Lower section for inventory and availability details |
AdditionalInfo.Title | Typography | Default copy is Inventory |
AdditionalInfo.Subtitle | Typography | Default copy is Location |
AdditionalInfo.PrefectureField | SelectField | name="prefecture", default label Prefecture, default placeholder Select prefecture |
AdditionalInfo.CityField | TextField | name="city", default label City, default placeholder Enter city |
AdditionalInfo.OnlineAvailabilityField | FormControlLabel + Checkbox | name="onlineAvailability", default label Online Availability |
AdditionalInfo.InventoryNotesField | TextField | name="inventoryNotes", default label Inventory Notes, default placeholder Notes about inventory, multiline |
AdditionalInfo.AvailableFromField | TimePicker | name="availableFrom", default label Available From, stored as HH:mm |
AdditionalInfo.AvailableUntilField | TimePicker | name="availableUntil", default label Available Until, stored as HH:mm |
AdditionalInfo.AdditionalDetailsField | TextField | name="additionalDetails", default label Additional Details, default placeholder Enter additional details, multiline |
Actions | Stack | Bottom action row centered on the page |
Actions.SubmitButton | Button | Default copy is Submit, with a leading check icon |
Extra field primitives
| Primitive | MUI base component(s) | Notes |
|---|---|---|
TextField | TextField | Shared controlled text primitive exported as CreateProduct.TextField |
NumberField | TextField | Shared controlled numeric primitive exported as CreateProduct.NumberField |
TimeField | TimePicker | Shared controlled time primitive exported as CreateProduct.TimeField; stores values as HH:mm |
CheckboxField | FormControlLabel + Checkbox | Shared controlled checkbox primitive exported as CreateProduct.CheckboxField |
SelectField | TextField | Shared controlled select primitive exported as CreateProduct.SelectField |
TypeScript
export type CreateProductFormData =
| {
title: string;
categoryId: string;
typeId: string;
optionId: string;
quantity: string;
description: string;
details: string;
tags: Tag[];
prefecture: string;
city: string;
onlineAvailability: boolean;
inventoryNotes: string;
availableFrom: string;
availableUntil: string;
additionalDetails: string;
image: { url: string; type?: string; id?: string } | File | null;
}
| Record<string, unknown>;