feat(admin-ui, medusa): admin UI metadata (#3644)
This commit is contained in:
committed by
GitHub
parent
4f4ccee7fb
commit
4342ac884b
@@ -11,7 +11,7 @@ import InputHeader from "../../fundamentals/input-header"
|
||||
import CustomHeader from "./custom-header"
|
||||
import { DateTimePickerProps } from "./types"
|
||||
|
||||
const getDateClassname = (d, tempDate) => {
|
||||
const getDateClassname = (d: Date, tempDate: Date) => {
|
||||
return moment(d).format("YY,MM,DD") === moment(tempDate).format("YY,MM,DD")
|
||||
? "date chosen"
|
||||
: `date ${
|
||||
@@ -29,12 +29,18 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
|
||||
tooltipContent,
|
||||
tooltip,
|
||||
}) => {
|
||||
const [tempDate, setTempDate] = useState(date)
|
||||
const [tempDate, setTempDate] = useState<Date | null>(date || null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => setTempDate(date), [isOpen])
|
||||
|
||||
const submitDate = () => {
|
||||
if (!tempDate || !date) {
|
||||
onSubmitDate(null)
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
// update only date, month and year
|
||||
const newDate = new Date(date.getTime())
|
||||
newDate.setUTCDate(tempDate.getUTCDate())
|
||||
@@ -68,7 +74,9 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
|
||||
<ArrowDownIcon size={16} />
|
||||
</div>
|
||||
<label className="w-full text-left">
|
||||
{moment(date).format("ddd, DD MMM YYYY")}
|
||||
{date
|
||||
? moment(date).format("ddd, DD MMM YYYY")
|
||||
: "---, -- -- ----"}
|
||||
</label>
|
||||
</InputContainer>
|
||||
</button>
|
||||
@@ -78,7 +86,10 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
|
||||
sideOffset={8}
|
||||
className="rounded-rounded border-grey-20 bg-grey-0 shadow-dropdown w-full border px-8"
|
||||
>
|
||||
<CalendarComponent date={tempDate} onChange={setTempDate} />
|
||||
<CalendarComponent
|
||||
date={tempDate}
|
||||
onChange={(date) => setTempDate(date)}
|
||||
/>
|
||||
<div className="mb-8 mt-5 flex w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -101,7 +112,18 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const CalendarComponent = ({ date, onChange }) => (
|
||||
type CalendarComponentProps = {
|
||||
date: Date | null
|
||||
onChange: (
|
||||
date: Date | null,
|
||||
event: React.SyntheticEvent<any, Event> | undefined
|
||||
) => void
|
||||
}
|
||||
|
||||
export const CalendarComponent = ({
|
||||
date,
|
||||
onChange,
|
||||
}: CalendarComponentProps) => (
|
||||
<ReactDatePicker
|
||||
selected={date}
|
||||
inline
|
||||
|
||||
@@ -70,7 +70,7 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
|
||||
<ClockIcon size={16} />
|
||||
<span className="mx-1">UTC</span>
|
||||
<span className="text-grey-90">
|
||||
{moment.utc(date).format("HH:mm")}
|
||||
{date ? moment.utc(date).format("HH:mm") : "--:--"}
|
||||
</span>
|
||||
</div>
|
||||
</InputContainer>
|
||||
@@ -84,13 +84,13 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
|
||||
<NumberScroller
|
||||
numbers={hourNumbers}
|
||||
selected={selectedHour}
|
||||
onSelect={(n) => setSelectedHour(n)}
|
||||
onSelect={(n) => setSelectedHour(n as number)}
|
||||
className="pr-4"
|
||||
/>
|
||||
<NumberScroller
|
||||
numbers={minuteNumbers}
|
||||
selected={selectedMinute}
|
||||
onSelect={(n) => setSelectedMinute(n)}
|
||||
onSelect={(n) => setSelectedMinute(n as number)}
|
||||
/>
|
||||
<div className="to-grey-0 h-xlarge absolute bottom-4 left-0 right-0 z-10 bg-gradient-to-b from-transparent" />
|
||||
</PopoverPrimitive.Content>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InputHeaderProps } from "../../fundamentals/input-header"
|
||||
|
||||
export type DateTimePickerProps = {
|
||||
date: Date
|
||||
onSubmitDate: (newDate: Date) => void
|
||||
date: Date | null
|
||||
onSubmitDate: (newDate: Date | null) => void
|
||||
} & InputHeaderProps
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputField from "../../../molecules/input"
|
||||
|
||||
export type CustomerGroupGeneralFormType = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type CustomerGroupGeneralFormProps = {
|
||||
form: NestedForm<CustomerGroupGeneralFormType>
|
||||
}
|
||||
|
||||
export const CustomerGroupGeneralForm = ({
|
||||
form,
|
||||
}: CustomerGroupGeneralFormProps) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InputField
|
||||
label="Name"
|
||||
required
|
||||
{...register(path("name"), {
|
||||
required: FormValidator.required("Name"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import DatePicker from "../../../atoms/date-picker/date-picker"
|
||||
import TimePicker from "../../../atoms/date-picker/time-picker"
|
||||
import AvailabilityDuration from "../../../molecules/availability-duration"
|
||||
import InputField from "../../../molecules/input"
|
||||
import SwitchableItem from "../../../molecules/switchable-item"
|
||||
|
||||
export type DiscountConfigurationFormType = {
|
||||
starts_at: Date
|
||||
ends_at: Date | null
|
||||
usage_limit: number | null
|
||||
valid_duration: string | null
|
||||
}
|
||||
|
||||
type DiscountConfigurationFormProps = {
|
||||
form: NestedForm<DiscountConfigurationFormType>
|
||||
isDynamic?: boolean
|
||||
}
|
||||
|
||||
const DiscountConfigurationForm = ({
|
||||
form,
|
||||
isDynamic,
|
||||
}: DiscountConfigurationFormProps) => {
|
||||
const { control, path } = form
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-y-large flex flex-col">
|
||||
<Controller
|
||||
name={path("starts_at")}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SwitchableItem
|
||||
open={!!value}
|
||||
onSwitch={() => {
|
||||
if (value) {
|
||||
onChange(null)
|
||||
} else {
|
||||
onChange(new Date())
|
||||
}
|
||||
}}
|
||||
title="Discount has a start date?"
|
||||
description="Schedule the discount to activate in the future."
|
||||
>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<DatePicker
|
||||
date={value!}
|
||||
label="Start date"
|
||||
onSubmitDate={onChange}
|
||||
/>
|
||||
<TimePicker
|
||||
label="Start time"
|
||||
date={value!}
|
||||
onSubmitDate={onChange}
|
||||
/>
|
||||
</div>
|
||||
</SwitchableItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name={path("ends_at")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SwitchableItem
|
||||
open={!!value}
|
||||
onSwitch={() => {
|
||||
if (value) {
|
||||
onChange(null)
|
||||
} else {
|
||||
onChange(
|
||||
new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
)
|
||||
}
|
||||
}}
|
||||
title="Discount has an expiry date?"
|
||||
description="Schedule the discount to deactivate in the future."
|
||||
>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<DatePicker
|
||||
date={value!}
|
||||
label="Expiry date"
|
||||
onSubmitDate={onChange}
|
||||
/>
|
||||
<TimePicker
|
||||
label="Expiry time"
|
||||
date={value!}
|
||||
onSubmitDate={onChange}
|
||||
/>
|
||||
</div>
|
||||
</SwitchableItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name={path("usage_limit")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SwitchableItem
|
||||
open={!!value}
|
||||
onSwitch={() => {
|
||||
if (value) {
|
||||
onChange(null)
|
||||
} else {
|
||||
onChange(10)
|
||||
}
|
||||
}}
|
||||
title="Limit the number of redemtions?"
|
||||
description="Limit applies across all customers, not per customer."
|
||||
>
|
||||
<InputField
|
||||
label="Number of redemptions"
|
||||
type="number"
|
||||
placeholder="5"
|
||||
min={1}
|
||||
defaultValue={value ?? undefined}
|
||||
onChange={(value) => onChange(value.target.valueAsNumber)}
|
||||
/>
|
||||
</SwitchableItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{isDynamic && (
|
||||
<Controller
|
||||
name={path("valid_duration")}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SwitchableItem
|
||||
open={!!value}
|
||||
onSwitch={() => {
|
||||
if (value) {
|
||||
onChange(null)
|
||||
} else {
|
||||
onChange("P0Y0M0DT00H00M")
|
||||
}
|
||||
}}
|
||||
title="Availability duration?"
|
||||
description="Set the duration of the discount."
|
||||
>
|
||||
<AvailabilityDuration
|
||||
value={value ?? undefined}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</SwitchableItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscountConfigurationForm
|
||||
@@ -0,0 +1,192 @@
|
||||
import clsx from "clsx"
|
||||
import { useAdminRegions } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
import { Controller, useWatch } from "react-hook-form"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputError from "../../../atoms/input-error"
|
||||
import IconTooltip from "../../../molecules/icon-tooltip"
|
||||
import IndeterminateCheckbox from "../../../molecules/indeterminate-checkbox"
|
||||
import InputField from "../../../molecules/input"
|
||||
import { NextSelect } from "../../../molecules/select/next-select"
|
||||
import TextArea from "../../../molecules/textarea"
|
||||
import PriceFormInput from "../../general/prices-form/price-form-input"
|
||||
|
||||
type DiscountRegionOption = Option & {
|
||||
currency_code: string
|
||||
}
|
||||
|
||||
enum DiscountRuleType {
|
||||
FIXED = "fixed",
|
||||
PERCENTAGE = "percentage",
|
||||
FREE_SHIPPING = "free_shipping",
|
||||
}
|
||||
|
||||
export type DiscountGeneralFormType = {
|
||||
region_ids: DiscountRegionOption[]
|
||||
code: string
|
||||
value?: number
|
||||
description: string
|
||||
is_dynamic?: boolean
|
||||
}
|
||||
|
||||
type DiscountGeneralFormProps = {
|
||||
form: NestedForm<DiscountGeneralFormType>
|
||||
type: DiscountRuleType
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
const DiscountGeneralForm = ({
|
||||
form,
|
||||
type,
|
||||
isEdit,
|
||||
}: DiscountGeneralFormProps) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const { regions } = useAdminRegions()
|
||||
|
||||
const regionOptions = useMemo(() => {
|
||||
return (
|
||||
regions?.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
currency_code: r.currency_code,
|
||||
})) || []
|
||||
)
|
||||
}, [regions])
|
||||
|
||||
const selectedRegionCurrency = useWatch({
|
||||
control,
|
||||
name: path("region_ids.0.currency_code"),
|
||||
defaultValue: "usd",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="gap-y-large flex flex-col">
|
||||
<Controller
|
||||
name={path("region_ids")}
|
||||
control={control}
|
||||
rules={{
|
||||
required: FormValidator.required("Regions"),
|
||||
}}
|
||||
render={({ field: { value, onChange, name, ref } }) => {
|
||||
return (
|
||||
<NextSelect
|
||||
name={name}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
onChange(type === DiscountRuleType.FIXED ? [value] : value)
|
||||
}}
|
||||
label="Choose valid regions"
|
||||
isMulti={type !== DiscountRuleType.FIXED}
|
||||
selectAll={type !== DiscountRuleType.FIXED}
|
||||
isSearchable
|
||||
required
|
||||
options={regionOptions}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={clsx("gap-small grid", {
|
||||
"grid-cols-2":
|
||||
type === DiscountRuleType.FIXED ||
|
||||
type === DiscountRuleType.PERCENTAGE,
|
||||
"grid-cols-1": type === DiscountRuleType.FREE_SHIPPING,
|
||||
})}
|
||||
>
|
||||
<InputField
|
||||
label="Code"
|
||||
required
|
||||
errors={errors}
|
||||
{...register(path("code"), {
|
||||
required: FormValidator.required("Code"),
|
||||
})}
|
||||
/>
|
||||
{type === DiscountRuleType.FIXED ? (
|
||||
<Controller
|
||||
name={path("value")}
|
||||
rules={{
|
||||
required: FormValidator.required("Amount"),
|
||||
shouldUnregister: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<PriceFormInput
|
||||
label="Amount"
|
||||
amount={value}
|
||||
onChange={onChange}
|
||||
currencyCode={selectedRegionCurrency}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : type === DiscountRuleType.PERCENTAGE ? (
|
||||
<InputField
|
||||
label="Percentage"
|
||||
placeholder="Percentage"
|
||||
errors={errors}
|
||||
prefix="%"
|
||||
required
|
||||
{...register(path("value"), {
|
||||
valueAsNumber: true,
|
||||
required: FormValidator.required("Percentage"),
|
||||
shouldUnregister: true,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="inter-small-regular text-grey-50 mt-small max-w-[60%]">
|
||||
The code your customers will enter during checkout. This will appear
|
||||
on your customer's invoice. Uppercase letters and numbers only.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
label="Description"
|
||||
errors={errors}
|
||||
required
|
||||
{...register(path("description"), {
|
||||
required: FormValidator.required("Description"),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Controller
|
||||
name={path("is_dynamic")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ref } }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<IndeterminateCheckbox
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
/>
|
||||
<p className="ml-small mr-xsmall">Template discount</p>
|
||||
<IconTooltip content="Template discounts allow you to define a set of rules that can be used across a group of discounts. This is useful in campaigns that should generate unique codes for each user, but where the rules for all unique codes should be the same." />
|
||||
</div>
|
||||
<InputError errors={errors} name={path("is_dynamic")} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscountGeneralForm
|
||||
@@ -0,0 +1,87 @@
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputField from "../../../molecules/input"
|
||||
|
||||
export type AddressContactFormType = {
|
||||
first_name: string
|
||||
last_name: string
|
||||
company: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
type AddressContactFormProps = {
|
||||
requireFields?: Partial<Record<keyof AddressContactFormType, boolean>>
|
||||
form: NestedForm<AddressContactFormType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable form for address contact information, used to create and edit addresses.
|
||||
* Fields are optional, but can be required by passing in a requireFields object.
|
||||
*/
|
||||
const AddressContactForm = ({
|
||||
form,
|
||||
requireFields,
|
||||
}: AddressContactFormProps) => {
|
||||
const {
|
||||
path,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-large grid grid-cols-2">
|
||||
<InputField
|
||||
{...register(path("first_name"), {
|
||||
required: requireFields?.first_name
|
||||
? FormValidator.required("First name")
|
||||
: false,
|
||||
pattern: FormValidator.whiteSpaceRule("First name"),
|
||||
})}
|
||||
placeholder="First Name"
|
||||
label="First Name"
|
||||
required={requireFields?.first_name}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...form.register(path("last_name"), {
|
||||
required: requireFields?.last_name
|
||||
? FormValidator.required("Last name")
|
||||
: false,
|
||||
pattern: FormValidator.whiteSpaceRule("Last name"),
|
||||
})}
|
||||
placeholder="Last Name"
|
||||
label="Last Name"
|
||||
required={requireFields?.last_name}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...form.register(path("company"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Company"),
|
||||
required: requireFields?.company
|
||||
? FormValidator.required("Company")
|
||||
: false,
|
||||
})}
|
||||
placeholder="Company"
|
||||
required={requireFields?.company}
|
||||
label="Company"
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...form.register(path("phone"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Phone"),
|
||||
required: requireFields?.phone
|
||||
? FormValidator.required("Phone")
|
||||
: false,
|
||||
})}
|
||||
required={requireFields?.phone}
|
||||
placeholder="Phone"
|
||||
label="Phone"
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressContactForm
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputField from "../../../molecules/input"
|
||||
import { NextSelect } from "../../../molecules/select/next-select"
|
||||
|
||||
export type AddressLocationFormType = {
|
||||
address_1: string
|
||||
address_2: string | null
|
||||
city: string
|
||||
province: string | null
|
||||
country_code: Option
|
||||
postal_code: string
|
||||
}
|
||||
|
||||
type AddressLocationFormProps = {
|
||||
requireFields?: Partial<Record<keyof AddressLocationFormType, boolean>>
|
||||
countryOptions?: Option[]
|
||||
form: NestedForm<AddressLocationFormType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable form for address location information, used to create and edit addresses.
|
||||
* Fields are optional, but can be required by passing in a requireFields object.
|
||||
*/
|
||||
const AddressLocationForm = ({
|
||||
form,
|
||||
countryOptions,
|
||||
requireFields,
|
||||
}: AddressLocationFormProps) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div className="gap-large grid grid-cols-2">
|
||||
<InputField
|
||||
{...register(path("address_1"), {
|
||||
required: requireFields?.address_1
|
||||
? FormValidator.required("Address 1")
|
||||
: false,
|
||||
pattern: FormValidator.whiteSpaceRule("Address 1"),
|
||||
})}
|
||||
placeholder="Address 1"
|
||||
label="Address 1"
|
||||
required={requireFields?.address_1}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...register(path("address_2"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Address 2"),
|
||||
required: requireFields?.address_2
|
||||
? FormValidator.required("Address 2")
|
||||
: false,
|
||||
})}
|
||||
placeholder="Address 2"
|
||||
required={requireFields?.address_2}
|
||||
label="Address 2"
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...register(path("postal_code"), {
|
||||
required: requireFields?.postal_code
|
||||
? FormValidator.required("Postal code")
|
||||
: false,
|
||||
pattern: FormValidator.whiteSpaceRule("Postal code"),
|
||||
})}
|
||||
placeholder="Postal code"
|
||||
label="Postal code"
|
||||
required={requireFields?.postal_code}
|
||||
autoComplete="off"
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
placeholder="City"
|
||||
label="City"
|
||||
{...register(path("city"), {
|
||||
required: requireFields?.city
|
||||
? FormValidator.required("City")
|
||||
: false,
|
||||
pattern: FormValidator.whiteSpaceRule("City"),
|
||||
})}
|
||||
required={requireFields?.city}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
{...register(path("province"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Province"),
|
||||
required: requireFields?.province
|
||||
? FormValidator.required("Province")
|
||||
: false,
|
||||
})}
|
||||
placeholder="Province"
|
||||
label="Province"
|
||||
required={requireFields?.province}
|
||||
errors={errors}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("country_code")}
|
||||
rules={{
|
||||
required: requireFields?.country_code
|
||||
? FormValidator.required("Country")
|
||||
: false,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<NextSelect
|
||||
label="Country"
|
||||
required={requireFields?.country_code}
|
||||
value={value}
|
||||
options={countryOptions}
|
||||
onChange={onChange}
|
||||
name={path("country_code")}
|
||||
errors={errors}
|
||||
isClearable={!requireFields?.country_code}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressLocationForm
|
||||
@@ -0,0 +1,371 @@
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||
import clsx from "clsx"
|
||||
import React, { useMemo } from "react"
|
||||
import { useFieldArray, useWatch } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import ArrowDownIcon from "../../../fundamentals/icons/arrow-down-icon"
|
||||
import ArrowUpIcon from "../../../fundamentals/icons/arrow-up-icon"
|
||||
import DuplicateIcon from "../../../fundamentals/icons/duplicate-icon"
|
||||
import EllipsisVerticalIcon from "../../../fundamentals/icons/ellipsis-vertical-icon"
|
||||
import TrashIcon from "../../../fundamentals/icons/trash-icon"
|
||||
import WarningCircleIcon from "../../../fundamentals/icons/warning-circle"
|
||||
import XCircleIcon from "../../../fundamentals/icons/x-circle-icon"
|
||||
|
||||
export type MetadataField = {
|
||||
key: string
|
||||
value: string
|
||||
state?: "existing" | "new"
|
||||
}
|
||||
|
||||
export type MetadataFormType = {
|
||||
entries: MetadataField[]
|
||||
deleted?: string[]
|
||||
ignored?: Record<string, any>
|
||||
}
|
||||
|
||||
type MetadataProps = {
|
||||
form: NestedForm<MetadataFormType>
|
||||
}
|
||||
|
||||
const MetadataForm = ({ form }: MetadataProps) => {
|
||||
const { control, path, register, setValue, getValues } = form
|
||||
|
||||
const { fields, remove, insert } = useFieldArray({
|
||||
control,
|
||||
name: path("entries"),
|
||||
keyName: "fieldKey",
|
||||
})
|
||||
|
||||
const handleInsertAbove = (index: number) => {
|
||||
insert(index, { key: "", value: "" })
|
||||
}
|
||||
|
||||
const handleInsertBelow = (index: number) => {
|
||||
insert(index + 1, { key: "", value: "" })
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
if (fields[index].state === "existing") {
|
||||
const deleted = getValues(path(`deleted`)) || []
|
||||
|
||||
setValue(path(`deleted`), [...deleted, fields[index].key], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (index === 0 && fields.length === 1) {
|
||||
setValue(path(`entries.${index}.value`), "", {
|
||||
shouldDirty: true,
|
||||
})
|
||||
setValue(path(`entries.${index}.key`), "", {
|
||||
shouldDirty: true,
|
||||
})
|
||||
} else {
|
||||
remove(index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = (index: number) => {
|
||||
insert(index + 1, { ...fields[index], state: undefined })
|
||||
}
|
||||
|
||||
const handleClearContents = (index: number) => {
|
||||
setValue(path(`entries.${index}.value`), "", {
|
||||
shouldDirty: true,
|
||||
})
|
||||
setValue(path(`entries.${index}.key`), "", {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
|
||||
const subscriber = useWatch({
|
||||
control,
|
||||
name: path("entries"),
|
||||
})
|
||||
|
||||
const ignoredSubscriber = useWatch({
|
||||
control,
|
||||
name: path("ignored"),
|
||||
defaultValue: {},
|
||||
})
|
||||
|
||||
const ignoredLength = useMemo(() => {
|
||||
return Object.keys(ignoredSubscriber || {}).length
|
||||
}, [ignoredSubscriber])
|
||||
|
||||
// If there is only one row and it is empty or there are no rows, disable the delete button
|
||||
const isDisabled = useMemo(() => {
|
||||
if (!subscriber?.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (subscriber?.length === 1) {
|
||||
return (
|
||||
(subscriber[0].key === "" && subscriber[0].value === "") ||
|
||||
(subscriber[0].key === undefined && subscriber[0].value === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}, [subscriber])
|
||||
|
||||
const rowClasses =
|
||||
"divide-grey-20 grid grid-cols-[165px_1fr] divide-x divide-solid [&>div]:px-base [&>div]:py-xsmall"
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-rounded border-grey-20 divide-grey-20 inter-base-regular divide-y border">
|
||||
<div
|
||||
className={clsx(
|
||||
"inter-small-semibold bg-grey-5 rounded-t-rounded",
|
||||
rowClasses
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p>Key</p>
|
||||
</div>
|
||||
<div className="">
|
||||
<p>Value</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-grey-20 divide-y">
|
||||
{!fields.length ? (
|
||||
<MetadataRow
|
||||
onClearContents={() => handleClearContents(0)}
|
||||
onDelete={() => handleDelete(0)}
|
||||
onDuplicate={() => handleDuplicate(0)}
|
||||
onInsertAbove={() => handleInsertAbove(0)}
|
||||
onInsertBelow={() => handleInsertBelow(0)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<div>
|
||||
<MetadataInput
|
||||
{...register(path(`entries.${0}.key`))}
|
||||
placeholder="Key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MetadataInput
|
||||
{...register(path(`entries.${0}.value`))}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
</MetadataRow>
|
||||
) : (
|
||||
fields.map((field, index) => {
|
||||
return (
|
||||
<MetadataRow
|
||||
key={field.fieldKey}
|
||||
onClearContents={() => handleClearContents(index)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
onDuplicate={() => handleDuplicate(index)}
|
||||
onInsertAbove={() => handleInsertAbove(index)}
|
||||
onInsertBelow={() => handleInsertBelow(index)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<div>
|
||||
<MetadataInput
|
||||
{...register(path(`entries.${index}.key`))}
|
||||
placeholder="Key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MetadataInput
|
||||
{...register(path(`entries.${index}.value`))}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
</MetadataRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ignoredLength > 0 && (
|
||||
<div className="rounded-rounded p-base gap-x-base mt-base flex items-start border border-[#FFD386] bg-[#FFECBC]">
|
||||
<div>
|
||||
<WarningCircleIcon
|
||||
fillType="solid"
|
||||
size={20}
|
||||
className="text-[#FFB224]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="inter-small-regular text-[#AD5700]">
|
||||
This entities metadata contains complex values that we currently
|
||||
don't support editing through the admin UI. Due to this{" "}
|
||||
{Object.keys(ignoredLength)} keys are currently not being
|
||||
displayed. You can still edit these values using the API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataForm
|
||||
|
||||
const MetadataInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentPropsWithoutRef<"input">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"placeholder:text-grey-40 placeholder:inter-base-regular w-full appearance-none outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
MetadataInput.displayName = "MetadataInput"
|
||||
|
||||
type MetadataRowProps = React.PropsWithChildren<{
|
||||
onDuplicate: () => void
|
||||
onInsertAbove: () => void
|
||||
onInsertBelow: () => void
|
||||
onDelete: () => void
|
||||
onClearContents: () => void
|
||||
isDisabled?: boolean
|
||||
}>
|
||||
|
||||
const MetadataRow = ({
|
||||
onDuplicate,
|
||||
onInsertAbove,
|
||||
onInsertBelow,
|
||||
onDelete,
|
||||
onClearContents,
|
||||
isDisabled = false,
|
||||
children,
|
||||
}: MetadataRowProps) => {
|
||||
const itemClasses =
|
||||
"px-base py-[6px] outline-none flex items-center gap-x-xsmall hover:bg-grey-5 focus:bg-grey-10 transition-colors cursor-pointer"
|
||||
|
||||
return (
|
||||
<div className="last-of-type:rounded-b-rounded group/metadata relative">
|
||||
<div className="divide-grey-20 [&>div]:px-base [&>div]:py-xsmall grid grid-cols-[165px_1fr] divide-x divide-solid">
|
||||
{children}
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={clsx(
|
||||
"h-xlarge w-large -right-small radix-state-open:opacity-100 absolute inset-y-1/2 -translate-y-1/2 transform opacity-0 transition-opacity group-hover/metadata:opacity-100"
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content asChild sideOffset={8}>
|
||||
<div className="bg-grey-0 shadow-dropdown border-grey-20 rounded-rounded z-50 overflow-hidden border">
|
||||
<DropdownMenu.Item onClick={onInsertAbove} className={itemClasses}>
|
||||
<ArrowUpIcon size={20} />
|
||||
<span>Insert above</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={onInsertBelow} className={itemClasses}>
|
||||
<ArrowDownIcon size={20} />
|
||||
<span>Insert below</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={onDuplicate} className={itemClasses}>
|
||||
<DuplicateIcon size={20} />
|
||||
<span>Duplicate</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={onClearContents}
|
||||
className={itemClasses}
|
||||
>
|
||||
<XCircleIcon size={20} />
|
||||
<span>Clear contents</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.DropdownMenuSeparator className="bg-grey-20 h-px w-full" />
|
||||
<DropdownMenu.Item
|
||||
onClick={onDelete}
|
||||
disabled={isDisabled}
|
||||
className={clsx(
|
||||
{
|
||||
"text-grey-40": isDisabled,
|
||||
"text-rose-50": !isDisabled,
|
||||
},
|
||||
itemClasses,
|
||||
"hover:bg-grey-0"
|
||||
)}
|
||||
>
|
||||
<TrashIcon size={20} />
|
||||
<span>Delete</span>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getSubmittableMetadata = (
|
||||
data: MetadataFormType
|
||||
): Record<string, unknown> => {
|
||||
const metadata = data.entries.reduce((acc, { key, value }) => {
|
||||
if (key) {
|
||||
acc[key] = value
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
|
||||
if (data.deleted?.length) {
|
||||
data.deleted.forEach((key) => {
|
||||
metadata[key] = ""
|
||||
})
|
||||
}
|
||||
|
||||
// Preserve complex values that we don't support editing through the UI
|
||||
if (data.ignored) {
|
||||
Object.entries(data.ignored).forEach(([key, value]) => {
|
||||
metadata[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
export const getMetadataFormValues = (
|
||||
metadata?: Record<string, any> | null
|
||||
): MetadataFormType => {
|
||||
const data: MetadataFormType = {
|
||||
entries: [],
|
||||
deleted: [],
|
||||
ignored: {},
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
if (isPrimitive(value)) {
|
||||
data.entries.push({
|
||||
key,
|
||||
value: value as string,
|
||||
state: "existing",
|
||||
})
|
||||
} else {
|
||||
data.ignored![key] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const isPrimitive = (value: any): boolean => {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import clsx from "clsx"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import AmountField from "react-currency-input-field"
|
||||
import { currencies } from "../../../../utils/currencies"
|
||||
import InputError from "../../../atoms/input-error"
|
||||
import InputHeader from "../../../fundamentals/input-header"
|
||||
|
||||
type Props = {
|
||||
currencyCode: string
|
||||
@@ -10,6 +11,8 @@ type Props = {
|
||||
onChange: (amount?: number) => void
|
||||
errors?: { [x: string]: unknown }
|
||||
name?: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const PriceFormInput = ({
|
||||
@@ -18,13 +21,18 @@ const PriceFormInput = ({
|
||||
errors,
|
||||
amount,
|
||||
onChange,
|
||||
label,
|
||||
required,
|
||||
}: Props) => {
|
||||
const { symbol_native, decimal_digits } =
|
||||
currencies[currencyCode.toUpperCase()]
|
||||
|
||||
const getFormattedValue = (value: number) => {
|
||||
return `${value / 10 ** decimal_digits}`
|
||||
}
|
||||
const getFormattedValue = useCallback(
|
||||
(value: number) => {
|
||||
return `${value / 10 ** decimal_digits}`
|
||||
},
|
||||
[decimal_digits]
|
||||
)
|
||||
|
||||
const [formattedValue, setFormattedValue] = useState<string | undefined>(
|
||||
amount !== null && amount !== undefined
|
||||
@@ -36,7 +44,7 @@ const PriceFormInput = ({
|
||||
if (amount) {
|
||||
setFormattedValue(getFormattedValue(amount))
|
||||
}
|
||||
}, [amount, decimal_digits])
|
||||
}, [amount, decimal_digits, getFormattedValue])
|
||||
|
||||
const onAmountChange = (value?: string, floatValue?: number | null) => {
|
||||
if (typeof floatValue === "number") {
|
||||
@@ -52,6 +60,7 @@ const PriceFormInput = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <InputHeader {...{ label, required }} className="mb-xsmall" />}
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-grey-5 border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60 flex h-10 w-full items-center border",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Controller, useFieldArray, useWatch } from "react-hook-form"
|
||||
import { currencies } from "../../../../utils/currencies"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import { nestedForm, NestedForm } from "../../../../utils/nested-form"
|
||||
import IncludesTaxTooltip from "../../../atoms/includes-tax-tooltip"
|
||||
import InputError from "../../../atoms/input-error"
|
||||
import Switch from "../../../atoms/switch"
|
||||
import CoinsIcon from "../../../fundamentals/icons/coins-icon"
|
||||
import IconTooltip from "../../../molecules/icon-tooltip"
|
||||
import MetadataForm, { MetadataFormType } from "../../general/metadata-form"
|
||||
import PriceFormInput from "../../general/prices-form/price-form-input"
|
||||
|
||||
type DenominationType = {
|
||||
@@ -18,6 +19,7 @@ export type DenominationFormType = {
|
||||
defaultDenomination: DenominationType
|
||||
currencyDenominations: DenominationType[]
|
||||
useSameValue: boolean
|
||||
metadata: MetadataFormType
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -51,7 +53,7 @@ const DenominationForm = ({ form }: Props) => {
|
||||
<div className="gap-y-xlarge flex flex-col">
|
||||
<div>
|
||||
<div className="gap-x-2xsmall flex items-center">
|
||||
<h2 className="inter-large-semibold">Default currency</h2>
|
||||
<h2 className="inter-base-semibold">Default currency</h2>
|
||||
<IconTooltip
|
||||
type="info"
|
||||
content="The denomination in your store's default currency"
|
||||
@@ -170,6 +172,10 @@ const DenominationForm = ({ form }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="inter-base-semibold mb-base">Metadata</h2>
|
||||
<MetadataForm form={nestedForm(form, "metadata")} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useMemo } from "react"
|
||||
import {
|
||||
useAdminCollections,
|
||||
useAdminProductCategories,
|
||||
useAdminProductTypes
|
||||
useAdminProductTypes,
|
||||
} from "medusa-react"
|
||||
|
||||
import { NestedMultiselectOption } from "../../../../domain/categories/components/multiselect"
|
||||
import { transformCategoryToNestedFormOptions } from "../../../../domain/categories/utils/transform-response"
|
||||
import {
|
||||
FeatureFlag, useFeatureFlag
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from "../../../../providers/feature-flag-provider"
|
||||
|
||||
const useOrganizeData = () => {
|
||||
@@ -19,12 +20,15 @@ const useOrganizeData = () => {
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
const { collections } = useAdminCollections()
|
||||
const { product_categories: categories = [] } =
|
||||
isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) &&
|
||||
useAdminProductCategories({
|
||||
const { product_categories: categories = [] } = useAdminProductCategories(
|
||||
{
|
||||
parent_category_id: "null",
|
||||
include_descendants_tree: true,
|
||||
})
|
||||
},
|
||||
{
|
||||
enabled: isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES),
|
||||
}
|
||||
)
|
||||
|
||||
const productTypeOptions = useMemo(() => {
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { nestedForm } from "../../../../../utils/nested-form"
|
||||
import IconTooltip from "../../../../molecules/icon-tooltip"
|
||||
import InputField from "../../../../molecules/input"
|
||||
import Accordion from "../../../../organisms/accordion"
|
||||
import MetadataForm, { MetadataFormType } from "../../../general/metadata-form"
|
||||
import { PricesFormType } from "../../../general/prices-form"
|
||||
import VariantPricesForm from "../variant-prices-form"
|
||||
|
||||
@@ -29,6 +30,7 @@ export type EditFlowVariantFormType = {
|
||||
}[]
|
||||
customs: CustomsFormType
|
||||
dimensions: DimensionsFormType
|
||||
metadata: MetadataFormType
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -125,6 +127,13 @@ const EditFlowVariantForm = ({ form, isEdit }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Metadata" value="metadata">
|
||||
<p className="inter-base-regular text-grey-50 mb-base">
|
||||
Metadata can be used to store additional information about the
|
||||
variant.
|
||||
</p>
|
||||
<MetadataForm form={nestedForm(form, "metadata")} />
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import IconProps from "../types/icon-type"
|
||||
|
||||
const EllipsisVerticalIcon = ({
|
||||
size = 20,
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M10 3.75C10.1989 3.75 10.3897 3.82902 10.5303 3.96967C10.671 4.11032 10.75 4.30109 10.75 4.5C10.75 4.69891 10.671 4.88968 10.5303 5.03033C10.3897 5.17098 10.1989 5.25 10 5.25C9.80109 5.25 9.61032 5.17098 9.46967 5.03033C9.32902 4.88968 9.25 4.69891 9.25 4.5C9.25 4.30109 9.32902 4.11032 9.46967 3.96967C9.61032 3.82902 9.80109 3.75 10 3.75ZM10 9.25C10.1989 9.25 10.3897 9.32902 10.5303 9.46967C10.671 9.61032 10.75 9.80109 10.75 10C10.75 10.1989 10.671 10.3897 10.5303 10.5303C10.3897 10.671 10.1989 10.75 10 10.75C9.80109 10.75 9.61032 10.671 9.46967 10.5303C9.32902 10.3897 9.25 10.1989 9.25 10C9.25 9.80109 9.32902 9.61032 9.46967 9.46967C9.61032 9.32902 9.80109 9.25 10 9.25ZM10.5303 14.9697C10.671 15.1103 10.75 15.3011 10.75 15.5C10.75 15.6989 10.671 15.8897 10.5303 16.0303C10.3897 16.171 10.1989 16.25 10 16.25C9.80109 16.25 9.61032 16.171 9.46967 16.0303C9.32902 15.8897 9.25 15.6989 9.25 15.5C9.25 15.3011 9.32902 15.1103 9.46967 14.9697C9.61032 14.829 9.80109 14.75 10 14.75C10.1989 14.75 10.3897 14.829 10.5303 14.9697Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default EllipsisVerticalIcon
|
||||
@@ -1,4 +1,3 @@
|
||||
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
@@ -6,11 +5,12 @@ import React, {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react"
|
||||
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
|
||||
|
||||
import clsx from "clsx"
|
||||
import InputError from "../../atoms/input-error"
|
||||
import MinusIcon from "../../fundamentals/icons/minus-icon"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
|
||||
InputHeaderProps & {
|
||||
@@ -26,7 +26,6 @@ export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
|
||||
props?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const InputField = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
@@ -161,4 +160,6 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
}
|
||||
)
|
||||
|
||||
InputField.displayName = "InputField"
|
||||
|
||||
export default InputField
|
||||
|
||||
@@ -85,7 +85,7 @@ const Item: React.FC<AccordionItemProps> = ({
|
||||
<AccordionPrimitive.Content
|
||||
forceMount={forceMountContent}
|
||||
className={clsx(
|
||||
"radix-state-closed:animate-accordion-close radix-state-open:animate-accordion-open overflow-hidden px-1"
|
||||
"radix-state-closed:animate-accordion-close radix-state-open:animate-accordion-open radix-state-closed:pointer-events-none px-1"
|
||||
)}
|
||||
>
|
||||
<div className="inter-base-regular group-radix-state-closed:animate-accordion-close">
|
||||
|
||||
@@ -18,6 +18,8 @@ type BodyCardProps = {
|
||||
status?: React.ReactNode
|
||||
customHeader?: React.ReactNode
|
||||
compact?: boolean
|
||||
footerMinHeight?: number
|
||||
setBorders?: boolean
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const BodyCard: React.FC<BodyCardProps> = ({
|
||||
@@ -94,7 +96,7 @@ const BodyCard: React.FC<BodyCardProps> = ({
|
||||
{children && (
|
||||
<div
|
||||
className={clsx("flex flex-col", {
|
||||
"my-large grow": !compact,
|
||||
grow: !compact,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -109,7 +109,7 @@ const EditDenominationsModal = ({
|
||||
<Modal open={open} handleClose={handleClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">Edit Denominations</h1>
|
||||
<h1 className="inter-xlarge-semibold">Edit Denomination</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
|
||||
@@ -6,13 +6,18 @@ import OrganizeForm, {
|
||||
OrganizeFormType,
|
||||
} from "../../forms/product/organize-form"
|
||||
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import MetadataForm, {
|
||||
getMetadataFormValues,
|
||||
getSubmittableMetadata,
|
||||
MetadataFormType,
|
||||
} from "../../forms/general/metadata-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
@@ -24,6 +29,7 @@ type GeneralFormWrapper = {
|
||||
general: GeneralFormType
|
||||
organize: OrganizeFormType
|
||||
discountable: DiscountableFormType
|
||||
metadata: MetadataFormType
|
||||
}
|
||||
|
||||
const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
@@ -76,6 +82,7 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
|
||||
categories: data.organize.categories?.map((id) => ({ id })),
|
||||
discountable: data.discountable.value,
|
||||
metadata: getSubmittableMetadata(data.metadata),
|
||||
},
|
||||
onReset
|
||||
)
|
||||
@@ -105,6 +112,10 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
form={nestedForm(form, "discountable")}
|
||||
isGiftCard={product.is_giftcard}
|
||||
/>
|
||||
<div className="mt-xlarge">
|
||||
<h2 className="inter-base-semibold mb-base">Metadata</h2>
|
||||
<MetadataForm form={nestedForm(form, "metadata")} />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
@@ -155,6 +166,7 @@ const getDefaultValues = (product: Product): GeneralFormWrapper => {
|
||||
discountable: {
|
||||
value: product.discountable,
|
||||
},
|
||||
metadata: getMetadataFormValues(product.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,18 @@ const ProductDetails = ({ product }: Props) => {
|
||||
title="Discountable"
|
||||
value={product.discountable ? "True" : "False"}
|
||||
/>
|
||||
<Detail
|
||||
title="Metadata"
|
||||
value={
|
||||
Object.entries(product.metadata || {}).length > 0
|
||||
? `${Object.entries(product.metadata || {}).length} ${
|
||||
Object.keys(product.metadata || {}).length === 1
|
||||
? "item"
|
||||
: "items"
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa"
|
||||
import { useContext, useEffect } from "react"
|
||||
import { useEffect } from "react"
|
||||
import EditFlowVariantForm, {
|
||||
EditFlowVariantFormType,
|
||||
} from "../../forms/product/variant-form/edit-flow-variant-form"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
useLayeredModal,
|
||||
} from "../../molecules/modal/layered-modal"
|
||||
|
||||
import { useMedusa } from "medusa-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { getSubmittableMetadata } from "../../forms/general/metadata-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
@@ -20,7 +21,8 @@ type Props = {
|
||||
}
|
||||
|
||||
const AddVariantModal = ({ open, onClose, product }: Props) => {
|
||||
const context = useContext(LayeredModalContext)
|
||||
const context = useLayeredModal()
|
||||
|
||||
const { client } = useMedusa()
|
||||
const form = useForm<EditFlowVariantFormType>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
@@ -160,6 +162,10 @@ const getDefaultValues = (product: Product): EditFlowVariantFormType => {
|
||||
hs_code: null,
|
||||
origin_country: null,
|
||||
},
|
||||
metadata: {
|
||||
entries: [],
|
||||
deleted: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +198,7 @@ export const createAddPayload = (
|
||||
: null,
|
||||
// @ts-ignore
|
||||
prices: priceArray,
|
||||
metadata: getSubmittableMetadata(data.metadata),
|
||||
title: data.general.title || `${options?.map((o) => o.value).join(" / ")}`,
|
||||
options: options.map((option) => ({
|
||||
option_id: option.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useContext } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { countries } from "../../../utils/countries"
|
||||
import { getMetadataFormValues } from "../../forms/general/metadata-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
import { createAddPayload } from "./add-variant-modal"
|
||||
@@ -204,6 +205,7 @@ export const getEditVariantDefaultValues = (
|
||||
})),
|
||||
},
|
||||
options,
|
||||
metadata: getMetadataFormValues(variant.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import EditFlowVariantForm, {
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../../hooks/use-edit-product-actions"
|
||||
import { getSubmittableMetadata } from "../../../forms/general/metadata-form"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import { LayeredModalContext } from "../../../molecules/modal/layered-modal"
|
||||
@@ -117,6 +118,7 @@ export const createUpdatePayload = (
|
||||
...stock,
|
||||
...dimensions,
|
||||
...customs,
|
||||
metadata: getSubmittableMetadata(data.metadata),
|
||||
// @ts-ignore
|
||||
origin_country: customs?.origin_country
|
||||
? customs.origin_country.value
|
||||
|
||||
@@ -78,12 +78,12 @@ const EditVariantsModal = ({ open, onClose, product }: Props) => {
|
||||
/>
|
||||
)
|
||||
},
|
||||
[product]
|
||||
[moveCard, product]
|
||||
)
|
||||
|
||||
const handleFormReset = () => {
|
||||
const handleFormReset = useCallback(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}
|
||||
}, [product, reset])
|
||||
|
||||
const resetAndClose = () => {
|
||||
handleFormReset()
|
||||
@@ -93,7 +93,7 @@ const EditVariantsModal = ({ open, onClose, product }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
handleFormReset()
|
||||
}, [product])
|
||||
}, [product, handleFormReset])
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
onUpdate(
|
||||
|
||||
@@ -28,7 +28,7 @@ function RawJSON(props: RawJSONProps) {
|
||||
|
||||
return (
|
||||
<BodyCard className={"mb-4 h-auto min-h-0 w-full"} title={title}>
|
||||
<div className="mt-4 flex flex-grow items-center">
|
||||
<div className="flex flex-grow items-center">
|
||||
<JSONView data={data} />
|
||||
</div>
|
||||
</BodyCard>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import { Option } from "../../types/shared"
|
||||
import FormValidator from "../../utils/form-validator"
|
||||
import { NestedForm } from "../../utils/nested-form"
|
||||
import { nestedForm, NestedForm } from "../../utils/nested-form"
|
||||
import MetadataForm, { MetadataFormType } from "../forms/general/metadata-form"
|
||||
import Input from "../molecules/input"
|
||||
import { NextSelect } from "../molecules/select/next-select"
|
||||
|
||||
@@ -16,6 +17,7 @@ export type AddressPayload = {
|
||||
country_code: Option
|
||||
postal_code: string
|
||||
phone: string | null
|
||||
metadata: MetadataFormType
|
||||
}
|
||||
|
||||
export enum AddressType {
|
||||
@@ -173,6 +175,10 @@ const AddressForm = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-xlarge gap-y-base flex flex-col">
|
||||
<span className="inter-base-semibold">Metadata</span>
|
||||
<MetadataForm form={nestedForm(form, "metadata")} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,20 @@ import {
|
||||
useAdminCreateCollection,
|
||||
useAdminUpdateCollection,
|
||||
} from "medusa-react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import MetadataForm, {
|
||||
getSubmittableMetadata,
|
||||
MetadataFormType,
|
||||
} from "../../forms/general/metadata-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import IconTooltip from "../../molecules/icon-tooltip"
|
||||
import InputField from "../../molecules/input"
|
||||
import Modal from "../../molecules/modal"
|
||||
import Metadata, { MetadataField } from "../../organisms/metadata"
|
||||
import { MetadataField } from "../../organisms/metadata"
|
||||
|
||||
type CollectionModalProps = {
|
||||
onClose: () => void
|
||||
@@ -23,6 +28,7 @@ type CollectionModalProps = {
|
||||
type CollectionModalFormData = {
|
||||
title: string
|
||||
handle: string | undefined
|
||||
metadata: MetadataFormType
|
||||
}
|
||||
|
||||
const CollectionModal: React.FC<CollectionModalProps> = ({
|
||||
@@ -35,51 +41,54 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
|
||||
)
|
||||
const { mutate: create, isLoading: creating } = useAdminCreateCollection()
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<CollectionModalFormData>()
|
||||
const form = useForm<CollectionModalFormData>({
|
||||
defaultValues: {
|
||||
title: collection?.title,
|
||||
handle: collection?.handle,
|
||||
metadata: {
|
||||
entries: Object.entries(collection?.metadata || {}).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
state: "existing",
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
const { register, handleSubmit, reset } = form
|
||||
|
||||
useEffect(() => {
|
||||
if (collection) {
|
||||
reset({
|
||||
title: collection.title,
|
||||
handle: collection.handle,
|
||||
metadata: {
|
||||
entries: Object.entries(collection.metadata || {}).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
state: "existing",
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [collection, reset])
|
||||
|
||||
const notification = useNotification()
|
||||
const [metadata, setMetadata] = useState<MetadataField[]>([])
|
||||
|
||||
if (isEdit && !collection) {
|
||||
throw new Error("Collection is required for edit")
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
register("title", { required: true })
|
||||
register("handle")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && collection) {
|
||||
reset({
|
||||
title: collection.title,
|
||||
handle: collection.handle,
|
||||
})
|
||||
|
||||
if (collection.metadata) {
|
||||
Object.entries(collection.metadata).map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
const newMeta = metadata
|
||||
newMeta.push({ key, value })
|
||||
setMetadata(newMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [collection, isEdit])
|
||||
|
||||
const submit = (data: CollectionModalFormData) => {
|
||||
if (isEdit) {
|
||||
update(
|
||||
{
|
||||
title: data.title,
|
||||
handle: data.handle,
|
||||
metadata: metadata.reduce((acc, next) => {
|
||||
return {
|
||||
...acc,
|
||||
[next.key]: next.value,
|
||||
}
|
||||
}, {}),
|
||||
metadata: getSubmittableMetadata(data.metadata),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -100,12 +109,7 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
|
||||
{
|
||||
title: data.title,
|
||||
handle: data.handle,
|
||||
metadata: metadata.reduce((acc, next) => {
|
||||
return {
|
||||
...acc,
|
||||
[next.key]: next.value,
|
||||
}
|
||||
}, {}),
|
||||
metadata: getSubmittableMetadata(data.metadata),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -159,8 +163,9 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-xlarge w-full">
|
||||
<Metadata setMetadata={setMetadata} metadata={metadata} />
|
||||
<div className="mt-xlarge">
|
||||
<h2 className="inter-base-semibold mb-base">Metadata</h2>
|
||||
<MetadataForm form={nestedForm(form, "metadata")} />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CustomerGroup } from "@medusajs/medusa"
|
||||
import { useAdminCustomerGroups } from "medusa-react"
|
||||
import { useContext } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
HeaderGroup,
|
||||
@@ -11,13 +10,11 @@ import {
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from "react-table"
|
||||
import CustomerGroupContext, {
|
||||
CustomerGroupContextContainer,
|
||||
} from "../../../domain/customers/groups/context/customer-group-context"
|
||||
import useQueryFilters from "../../../hooks/use-query-filters"
|
||||
import useSetSearchParams from "../../../hooks/use-set-search-params"
|
||||
import DetailsIcon from "../../fundamentals/details-icon"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import { ActionType } from "../../molecules/actionables"
|
||||
import Table from "../../molecules/table"
|
||||
import TableContainer from "../../organisms/table-container"
|
||||
import { CUSTOMER_GROUPS_TABLE_COLUMNS } from "./config"
|
||||
@@ -92,19 +89,19 @@ function CustomerGroupsTableRow(props: CustomerGroupsTableRowProps) {
|
||||
const { row } = props
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { showModal } = useContext(CustomerGroupContext)
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: showModal,
|
||||
icon: <EditIcon size={20} />,
|
||||
},
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Details",
|
||||
onClick: () => navigate(row.original.id),
|
||||
icon: <DetailsIcon size={20} />,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => {},
|
||||
icon: <TrashIcon size={20} />,
|
||||
variant: "danger",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -223,11 +220,7 @@ function CustomerGroupsTable(props: CustomerGroupsTableProps) {
|
||||
<Table.Body {...table.getTableBodyProps()}>
|
||||
{table.rows.map((row) => {
|
||||
table.prepareRow(row)
|
||||
return (
|
||||
<CustomerGroupContextContainer key={row.id} group={row.original}>
|
||||
<CustomerGroupsTableRow row={row} />
|
||||
</CustomerGroupContextContainer>
|
||||
)
|
||||
return <CustomerGroupsTableRow row={row} key={row.id} />
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const useCustomerOrdersColumns = (): Column<Order>[] => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gap-x-2xsmall flex">
|
||||
<div className="gap-x-2xsmall flex items-center">
|
||||
<div ref={containerRef} className="gap-x-xsmall flex">
|
||||
{visibleItems.map((item) => {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user