diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 7064f37dc0f..410a0417f15 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", @@ -30,6 +31,7 @@ "ace-builds": "^1.37.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "mobx": "^6.9.0", "react": "18.2.0", "react-ace": "^13.0.0", diff --git a/packages/admin-ui/src/AutoComplete/AutoComplete.stories.tsx b/packages/admin-ui/src/AutoComplete/AutoComplete.stories.tsx new file mode 100644 index 00000000000..b8a2ae70fcd --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/AutoComplete.stories.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { AutoComplete } from "./AutoComplete"; + +const meta: Meta = { + title: "Components/Form/AutoComplete", + component: AutoComplete, + tags: ["autodocs"], + argTypes: { + onValueChange: { action: "onValueChange" }, + onOpenChange: { action: "onOpenChange" } + }, + parameters: { + layout: "padded" + }, + render: args => { + const [value, setValue] = useState(args.value); + return ; + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + "Eastern Standard Time (EST)", + "Central Standard Time (CST)", + "Pacific Standard Time (PST)", + "Greenwich Mean Time (GMT)", + "Central European Time (CET)", + "Central Africa Time (CAT)", + "India Standard Time (IST)", + "China Standard Time (CST)", + "Japan Standard Time (JST)", + "Australian Western Standard Time (AWST)", + "New Zealand Standard Time (NZST)", + "Fiji Time (FJT)", + "Argentina Time (ART)", + "Bolivia Time (BOT)", + "Brasilia Time (BRT)" + ] + } +}; + +export const WithLabel: Story = { + args: { + ...Default.args, + label: "Any field label" + } +}; + +export const WithRequiredLabel: Story = { + args: { + ...Default.args, + label: "Any field label", + required: true + } +}; + +export const WithDescription: Story = { + args: { + ...Default.args, + description: "Provide the required information for processing your request." + } +}; + +export const WithNotes: Story = { + args: { + ...Default.args, + note: "Note: Ensure your selection or input is accurate before proceeding." + } +}; + +export const WithErrors: Story = { + args: { + ...Default.args, + validation: { + isValid: false, + message: "This field is required." + } + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + label: "Any field label", + disabled: true + } +}; + +export const FullExample: Story = { + args: { + ...Default.args, + label: "Any field label", + required: true, + description: "Provide the required information for processing your request.", + note: "Note: Ensure your selection or input is accurate before proceeding.", + validation: { + isValid: false, + message: "This field is required." + } + } +}; diff --git a/packages/admin-ui/src/AutoComplete/AutoComplete.tsx b/packages/admin-ui/src/AutoComplete/AutoComplete.tsx new file mode 100644 index 00000000000..20ee412883d --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/AutoComplete.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from "react"; +import { makeDecoratable } from "~/utils"; +import { + AutoCompletePrimitive, + AutoCompletePrimitiveProps +} from "./primitives/AutoCompletePrimitive"; +import { + FormComponentDescription, + FormComponentErrorMessage, + FormComponentLabel, + FormComponentNote, + FormComponentProps +} from "~/FormComponent"; + +type AutoCompleteProps = AutoCompletePrimitiveProps & FormComponentProps; + +const DecoratableAutoComplete = ({ + label, + description, + note, + required, + disabled, + validation, + ...props +}: AutoCompleteProps) => { + const { isValid: validationIsValid, message: validationMessage } = validation || {}; + const invalid = useMemo(() => validationIsValid === false, [validationIsValid]); + + return ( +
+ + + + + +
+ ); +}; + +const AutoComplete = makeDecoratable("AutoComplete", DecoratableAutoComplete); + +export { AutoComplete }; diff --git a/packages/admin-ui/src/AutoComplete/index.ts b/packages/admin-ui/src/AutoComplete/index.ts new file mode 100644 index 00000000000..099110def8d --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/index.ts @@ -0,0 +1,2 @@ +export * from "./AutoComplete"; +export * from "./primitives/AutoCompletePrimitive"; diff --git a/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.stories.tsx b/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.stories.tsx new file mode 100644 index 00000000000..9b0a0fcfdcc --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.stories.tsx @@ -0,0 +1,447 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { ReactComponent as SearchIcon } from "@material-design-icons/svg/outlined/search.svg"; +import { AutoCompletePrimitive } from "./AutoCompletePrimitive"; +import { Button } from "~/Button"; +import { Icon } from "~/Icon"; + +const meta: Meta = { + title: "Components/Form Primitives/Autocomplete", + component: AutoCompletePrimitive, + tags: ["autodocs"], + parameters: { + layout: "padded" + }, + argTypes: { + onValueChange: { action: "onValueChange" }, + onOpenChange: { action: "onOpenChange" } + }, + render: args => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ Current selected value:
{value}
+
+
+ ); + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + "Eastern Standard Time (EST)", + "Central Standard Time (CST)", + "Pacific Standard Time (PST)", + "Greenwich Mean Time (GMT)", + "Central European Time (CET)", + "Central Africa Time (CAT)", + "India Standard Time (IST)", + "China Standard Time (CST)", + "Japan Standard Time (JST)", + "Australian Western Standard Time (AWST)", + "New Zealand Standard Time (NZST)", + "Fiji Time (FJT)", + "Argentina Time (ART)", + "Bolivia Time (BOT)", + "Brasilia Time (BRT)" + ] + } +}; + +export const MediumSize: Story = { + args: { + ...Default.args, + size: "md" + } +}; + +export const LargeSize: Story = { + args: { + ...Default.args, + size: "lg" + } +}; + +export const ExtraLargeSize: Story = { + args: { + ...Default.args, + size: "xl" + } +}; + +export const WithStartIcon: Story = { + args: { + ...Default.args, + startIcon: } /> + } +}; + +export const WithLoading: Story = { + args: { + ...Default.args, + isLoading: true + } +}; + +export const WithoutResetAction: Story = { + args: { + ...Default.args, + displayResetAction: false + } +}; + +export const PrimaryVariant: Story = { + args: { + ...Default.args, + variant: "primary" + } +}; + +export const PrimaryVariantDisabled: Story = { + args: { + ...PrimaryVariant.args, + disabled: true + } +}; + +export const PrimaryVariantInvalid: Story = { + args: { + ...PrimaryVariant.args, + invalid: true + } +}; + +export const SecondaryVariant: Story = { + args: { + variant: "secondary", + placeholder: "Custom placeholder" + } +}; + +export const SecondaryVariantDisabled: Story = { + args: { + ...SecondaryVariant.args, + disabled: true + } +}; + +export const SecondaryVariantInvalid: Story = { + args: { + ...SecondaryVariant.args, + invalid: true + } +}; + +export const GhostVariant: Story = { + args: { + variant: "ghost", + placeholder: "Custom placeholder" + } +}; + +export const GhostVariantDisabled: Story = { + args: { + ...GhostVariant.args, + disabled: true + } +}; + +export const GhostVariantInvalid: Story = { + args: { + ...GhostVariant.args, + invalid: true + } +}; + +export const WithPredefinedValue: Story = { + args: { + ...Default.args, + value: "Eastern Standard Time (EST)" + } +}; + +export const WithCustomPlaceholder: Story = { + args: { + ...Default.args, + placeholder: "Custom placeholder" + } +}; + +export const WithCustomEmptyMessage: Story = { + args: { + ...Default.args, + emptyMessage: "Custom empty message" + } +}; + +export const WithFormattedOptions: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst" }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithCustomOptionRenderer: Story = { + args: { + ...Default.args, + options: [ + { + label: "Eastern Standard Time (EST)", + value: "est", + item: { + name: "Eastern Standard Time (EST)", + time_difference: "-5:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ" + } + }, + { + label: "Central Standard Time (CST)", + value: "cst", + item: { + name: "Central Standard Time (CST)", + time_difference: "-6:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ" + } + }, + { + label: "Pacific Standard Time (PST)", + value: "pst", + item: { + name: "Pacific Standard Time (PST)", + time_difference: "-8:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ" + } + }, + { + label: "Greenwich Mean Time (GMT)", + value: "gmt", + item: { + name: "Greenwich Mean Time (GMT)", + time_difference: "ยฑ0:00", + flag: "๐Ÿ‡ฌ๐Ÿ‡ง" + } + }, + { + label: "Central European Time (CET)", + value: "cet", + item: { + name: "Central European Time (CET)", + time_difference: "+1:00", + flag: "๐Ÿ‡ช๐Ÿ‡บ" + } + }, + { + label: "Central Africa Time (CAT)", + value: "cat", + item: { + name: "Central Africa Time (CAT)", + time_difference: "+2:00", + flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ" + } + }, + { + label: "India Standard Time (IST)", + value: "ist", + item: { + name: "India Standard Time (IST)", + time_difference: "+5:30", + flag: "๐Ÿ‡ฎ๐Ÿ‡ณ" + } + }, + { + label: "China Standard Time (CST)", + value: "cst_china", + item: { + name: "China Standard Time (CST)", + time_difference: "+8:00", + flag: "๐Ÿ‡จ๐Ÿ‡ณ" + } + }, + { + label: "Japan Standard Time (JST)", + value: "jst", + item: { + name: "Japan Standard Time (JST)", + time_difference: "+9:00", + flag: "๐Ÿ‡ฏ๐Ÿ‡ต" + } + }, + { + label: "Australian Western Standard Time (AWST)", + value: "awst", + item: { + name: "Australian Western Standard Time (AWST)", + time_difference: "+8:00", + flag: "๐Ÿ‡ฆ๐Ÿ‡บ" + } + }, + { + label: "New Zealand Standard Time (NZST)", + value: "nzst", + item: { + name: "New Zealand Standard Time (NZST)", + time_difference: "+12:00", + flag: "๐Ÿ‡ณ๐Ÿ‡ฟ" + } + }, + { + label: "Fiji Time (FJT)", + value: "fjt", + item: { + name: "Fiji Time (FJT)", + time_difference: "+12:00", + flag: "๐Ÿ‡ซ๐Ÿ‡ฏ" + } + }, + { + label: "Argentina Time (ART)", + value: "art", + item: { + name: "Argentina Time (ART)", + time_difference: "-3:00", + flag: "๐Ÿ‡ฆ๐Ÿ‡ท" + } + }, + { + label: "Bolivia Time (BOT)", + value: "bot", + item: { + name: "Bolivia Time (BOT)", + time_difference: "-4:00", + flag: "๐Ÿ‡ง๐Ÿ‡ด" + } + }, + { + label: "Brasilia Time (BRT)", + value: "brt", + item: { + name: "Brasilia Time (BRT)", + time_difference: "-3:00", + flag: "๐Ÿ‡ง๐Ÿ‡ท" + } + } + ], + optionRenderer: item => { + return ( +
+
+ {item.flag} + {item.name} +
+
{item.time_difference}
+
+ ); + } + } +}; + +export const WithSeparators: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst", separator: true }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat", separator: true }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst", separator: true }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt", separator: true }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithDisabledOptions: Story = { + args: { + options: [ + { label: "Eastern Standard Time (EST)", value: "est", disabled: true }, + { label: "Central Standard Time (CST)", value: "cst", disabled: true }, + { label: "Pacific Standard Time (PST)", value: "pst", disabled: true }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithExternalValueControl: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst" }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + }, + render: args => { + const [value, setValue] = useState(args.value); + return ( +
+
+ setValue(value)} + /> +
+
+
+
+ Current selected value:
{value}
+
+
+ ); + } +}; diff --git a/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.tsx b/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.tsx new file mode 100644 index 00000000000..a918d7b8d0d --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/AutoCompletePrimitive.tsx @@ -0,0 +1,140 @@ +import React, { KeyboardEvent } from "react"; +import { Command } from "~/Command"; +import { Popover } from "~/Popover"; +import { InputPrimitiveProps } from "~/Input"; +import { useAutoComplete } from "./useAutoComplete"; +import { AutoCompleteInputIcons, AutoCompleteList } from "./components"; +import { AutoCompleteOption } from "./domains"; + +type AutoCompletePrimitiveProps = Omit & { + /** + * Accessible label for the command menu. Not shown visibly. + */ + label?: string; + /** + * Message to display when there are no options. + */ + emptyMessage?: React.ReactNode; + /** + * Indicates if the autocomplete is loading options. + */ + isLoading?: boolean; + /** + * Message to display while loading options. + */ + loadingMessage?: React.ReactNode; + /** + * Callback triggered when the open state changes. + */ + onOpenChange?: (open: boolean) => void; + /** + * Callback triggered when the value changes. + */ + onValueChange: (value: string) => void; + /** + * Callback triggered to reset the value. + */ + onValueReset?: () => void; + /** + * List of options for the autocomplete. + */ + options?: AutoCompleteOption[]; + /** + * Custom renderer for the options. + */ + optionRenderer?: (item: any, index: number) => React.ReactNode; + /** + * Optional selected item. + */ + value?: string; + /** + * Indicates if the reset action should be displayed. + */ + displayResetAction?: boolean; +}; + +const AutoCompletePrimitive = (props: AutoCompletePrimitiveProps) => { + const { vm, setListOpenState, setSelectedOption, searchOption, resetSelectedOption } = + useAutoComplete(props); + + const handleKeyDown = React.useCallback( + (event: KeyboardEvent) => { + if (props.disabled) { + return; + } + + if (!vm.optionsListVm.isOpen) { + setListOpenState(true); + } + + if (event.key.toLowerCase() === "escape") { + setListOpenState(false); + } + + if (event.key.toLowerCase() === "backspace") { + setListOpenState(true); + const inputValue = (event.target as HTMLInputElement).value; + setSelectedOption(""); + searchOption(inputValue); + } + }, + [props.disabled, setListOpenState, setSelectedOption] + ); + + const handleSelectOption = React.useCallback( + (value: string) => { + setSelectedOption(value); + setListOpenState(false); + }, + [setSelectedOption, setListOpenState] + ); + + return ( + setListOpenState(true)}> + + + + setListOpenState(!vm.optionsListVm.isOpen)} + /> + } + onBlur={() => setListOpenState(false)} + onFocus={() => setListOpenState(true)} + /> + + + + e.preventDefault()} + > + + + + + + ); +}; + +export { AutoCompletePrimitive, type AutoCompletePrimitiveProps }; diff --git a/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteInputIcons.tsx b/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteInputIcons.tsx new file mode 100644 index 00000000000..5135538b3b8 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteInputIcons.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { ReactComponent as Close } from "@material-design-icons/svg/outlined/close.svg"; +import { ReactComponent as ChevronDown } from "@material-design-icons/svg/outlined/keyboard_arrow_down.svg"; +import { IconButton } from "~/Button"; +import { Icon } from "~/Icon"; + +interface AutoCompleteInputIconsProps { + displayResetAction: boolean; + isDisabled?: boolean; + onOpenChange: (open: boolean) => void; + onResetValue: () => void; +} + +export const AutoCompleteInputIcons = (props: AutoCompleteInputIconsProps) => { + return ( +
+ {props.displayResetAction && ( + } label={"Reset"} />} + disabled={props.isDisabled} + onClick={event => { + event.stopPropagation(); + props.onResetValue(); + }} + /> + )} + } + label={"Open list"} + onClick={event => { + event.stopPropagation(); + props.onOpenChange(true); + }} + /> +
+ ); +}; diff --git a/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteList.tsx b/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteList.tsx new file mode 100644 index 00000000000..148c9ec40ac --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/components/AutoCompleteList.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { Command } from "~/Command"; + +interface AutoCompleteListProps extends React.ComponentPropsWithoutRef { + options: CommandOptionFormatted[]; + emptyMessage?: React.ReactNode; + isEmpty?: boolean; + isLoading?: boolean; + loadingMessage?: React.ReactNode; + onOptionSelect: (value: string) => void; + optionRenderer?: (item: any, index: number) => React.ReactNode; +} + +export const AutoCompleteList = ({ + emptyMessage, + isEmpty, + isLoading, + loadingMessage, + onOptionSelect, + optionRenderer, + options, + ...props +}: AutoCompleteListProps) => { + const renderOptions = React.useCallback( + (items: CommandOptionFormatted[]) => { + if (isEmpty) { + return null; + } + + const elements = []; + + return items.reduce((acc, item, currentIndex) => { + acc.push( + onOptionSelect(item.value)} + onMouseDown={event => event.preventDefault()} + > + {optionRenderer && item.item + ? optionRenderer.call(this, item.item, currentIndex) + : item.label} + + ); + + // Conditionally render the separator if `separator` is true + if (item.separator) { + acc.push(); + } + + return acc; + }, elements); + }, + [onOptionSelect, optionRenderer, isEmpty] + ); + + return ( + + {isLoading ? ( + {loadingMessage} + ) : ( + renderOptions(options) + )} + {!isLoading && {emptyMessage}} + + ); +}; diff --git a/packages/admin-ui/src/AutoComplete/primitives/components/index.ts b/packages/admin-ui/src/AutoComplete/primitives/components/index.ts new file mode 100644 index 00000000000..9b5759e336f --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/components/index.ts @@ -0,0 +1,2 @@ +export * from "./AutoCompleteInputIcons"; +export * from "./AutoCompleteList"; diff --git a/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOption.ts b/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOption.ts new file mode 100644 index 00000000000..5bcc1c3a695 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOption.ts @@ -0,0 +1,3 @@ +import { AutoCompleteOptionDto } from "./AutoCompleteOptionDto"; + +export type AutoCompleteOption = AutoCompleteOptionDto | string; diff --git a/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOptionDto.ts b/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOptionDto.ts new file mode 100644 index 00000000000..0c3a6f24bda --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/domains/AutoCompleteOptionDto.ts @@ -0,0 +1,3 @@ +import { CommandOptionDto } from "~/Command"; + +export type AutoCompleteOptionDto = CommandOptionDto; diff --git a/packages/admin-ui/src/AutoComplete/primitives/domains/ListCache.ts b/packages/admin-ui/src/AutoComplete/primitives/domains/ListCache.ts new file mode 100644 index 00000000000..34d99cf2639 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/domains/ListCache.ts @@ -0,0 +1,78 @@ +import { makeAutoObservable, runInAction, toJS } from "mobx"; + +export type Constructor = new (...args: any[]) => T; + +export interface IListCachePredicate { + (item: T): boolean; +} + +export interface IListCacheItemUpdater { + (item: T): T; +} + +export interface IListCache { + count(): number; + clear(): void; + hasItems(): boolean; + getItems(predicate?: IListCachePredicate): T[]; + getItem(predicate: IListCachePredicate): T | undefined; + addItems(items: T[]): void; + updateItems(updater: IListCacheItemUpdater): void; + removeItems(predicate: IListCachePredicate): void; +} + +export class ListCache implements IListCache { + private state: T[]; + + constructor() { + this.state = []; + + makeAutoObservable(this); + } + + count() { + return this.state.length; + } + + clear() { + runInAction(() => { + this.state = []; + }); + } + + hasItems() { + return this.state.length > 0; + } + + getItems(predicate?: IListCachePredicate): T[] { + if (predicate) { + return [...this.state.filter(item => predicate(item)).map(item => toJS(item))]; + } + + return [...this.state.map(item => toJS(item))]; + } + + getItem(predicate: IListCachePredicate): T | undefined { + const item = this.state.find(item => predicate(item)); + + return item ? toJS(item) : undefined; + } + + addItems(items: T[]) { + runInAction(() => { + this.state = [...this.state, ...items]; + }); + } + + updateItems(updater: IListCacheItemUpdater) { + runInAction(() => { + this.state = [...this.state.map(item => updater(item))]; + }); + } + + removeItems(predicate: IListCachePredicate) { + runInAction(() => { + this.state = this.state.filter(item => !predicate(item)); + }); + } +} diff --git a/packages/admin-ui/src/AutoComplete/primitives/domains/index.ts b/packages/admin-ui/src/AutoComplete/primitives/domains/index.ts new file mode 100644 index 00000000000..5ac25dd186e --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/domains/index.ts @@ -0,0 +1,3 @@ +export * from "./AutoCompleteOption"; +export * from "./AutoCompleteOptionDto"; +export * from "./ListCache"; diff --git a/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteInputPresenter.ts b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteInputPresenter.ts new file mode 100644 index 00000000000..d921309e471 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteInputPresenter.ts @@ -0,0 +1,56 @@ +import { makeAutoObservable } from "mobx"; + +interface AutoCompleteInputPresenterParams { + value?: string; + placeholder?: string; + displayResetAction?: boolean; +} + +interface IAutoCompleteInputPresenter { + vm: { + placeholder: string; + value: string; + displayResetAction: boolean; + }; + init: (params: AutoCompleteInputPresenterParams) => void; + setValue: (query: string) => void; + resetValue: () => void; +} + +class AutoCompleteInputPresenter implements IAutoCompleteInputPresenter { + private searchQuery?: string = undefined; + private placeholder?: string = undefined; + private displayResetAction?: boolean = true; + + constructor() { + makeAutoObservable(this); + } + + init(params?: AutoCompleteInputPresenterParams) { + this.searchQuery = params?.value; + this.placeholder = params?.placeholder; + this.displayResetAction = params?.displayResetAction ?? true; + } + + get vm() { + return { + placeholder: this.placeholder || "Start typing or select", + value: this.searchQuery || "", + displayResetAction: Boolean(this.displayResetAction && !!this.searchQuery) + }; + } + + public setValue = (value: string) => { + this.searchQuery = value; + }; + + public resetValue = () => { + this.searchQuery = undefined; + }; +} + +export { + AutoCompleteInputPresenter, + type IAutoCompleteInputPresenter, + type AutoCompleteInputPresenterParams +}; diff --git a/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteListOptionsPresenter.ts b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteListOptionsPresenter.ts new file mode 100644 index 00000000000..c55e50e6c07 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompleteListOptionsPresenter.ts @@ -0,0 +1,85 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { CommandOptionFormatter } from "~/Command/domain/CommandOptionFormatter"; +import { ListCache } from "../domains"; + +export interface IAutoCompleteListOptionsPresenterParams { + options?: CommandOption[]; + emptyMessage?: any; + loadingMessage?: any; +} + +export interface IAutoCompleteListOptionsPresenter { + vm: { + options: CommandOptionFormatted[]; + emptyMessage: string; + loadingMessage: string; + isOpen: boolean; + isEmpty: boolean; + }; + init: (params: IAutoCompleteListOptionsPresenterParams) => void; + setListOpenState: (open: boolean) => void; + setSelectedOption: (value: string) => void; + removeSelectedOption: (value: string) => void; + resetSelectedOption: () => void; +} + +export class AutoCompleteListOptionsPresenter implements IAutoCompleteListOptionsPresenter { + private isOpen = false; + private emptyMessage = "No results."; + private loadingMessage = "Loading..."; + private options = new ListCache(); + + constructor() { + makeAutoObservable(this); + } + + init(params: IAutoCompleteListOptionsPresenterParams) { + this.options.clear(); + params.options && this.options.addItems(params.options); + this.emptyMessage = params.emptyMessage || this.emptyMessage; + this.loadingMessage = params.loadingMessage || this.loadingMessage; + } + + get vm() { + return { + options: this.options.getItems().map(option => CommandOptionFormatter.format(option)), + emptyMessage: this.emptyMessage, + loadingMessage: this.loadingMessage, + isOpen: this.isOpen, + isEmpty: !this.options.hasItems() + }; + } + + setListOpenState = (open: boolean) => { + this.isOpen = open; + }; + + setSelectedOption = (value: string) => { + this.options.updateItems(option => { + if (option.value === value) { + option.selected = true; + } + + return option; + }); + }; + + removeSelectedOption = (value: string) => { + this.options.updateItems(option => { + if (option.value === value) { + option.selected = false; + } + + return option; + }); + }; + + resetSelectedOption = () => { + this.options.updateItems(option => { + option.selected = false; + return option; + }); + }; +} diff --git a/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.test.ts b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.test.ts new file mode 100644 index 00000000000..48771a0c7ed --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.test.ts @@ -0,0 +1,407 @@ +import { AutoCompletePresenter } from "./AutoCompletePresenter"; +import { AutoCompleteInputPresenter } from "./AutoCompleteInputPresenter"; +import { AutoCompleteListOptionsPresenter } from "./AutoCompleteListOptionsPresenter"; + +describe("AutoCompletePresenter", () => { + const inputPresenter = new AutoCompleteInputPresenter(); + const optionsListPresenter = new AutoCompleteListOptionsPresenter(); + const presenter = new AutoCompletePresenter(inputPresenter, optionsListPresenter); + const onValueChange = jest.fn(); + + it("should return the compatible `vm.inputVm` based on params", () => { + // `placeholder` + { + presenter.init({ onValueChange, placeholder: "Custom placeholder" }); + expect(presenter.vm.inputVm.placeholder).toEqual("Custom placeholder"); + } + + { + // default: no params + presenter.init({ onValueChange }); + expect(presenter.vm.inputVm.placeholder).toEqual("Start typing or select"); + expect(presenter.vm.inputVm.displayResetAction).toEqual(false); + } + }); + + it("should return the compatible `vm.optionsListVm` based on params", () => { + // with `options` as string + { + presenter.init({ onValueChange, options: ["Option 1", "Option 2"] }); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + value: "Option 2", + label: "Option 2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + // with `options` as formatted options + { + presenter.init({ + onValueChange, + options: [ + { + value: "option-1", + label: "Option 1" + }, + { + value: "option-2", + label: "Option 2" + }, + { + value: "option-3", + label: "Option 3", + disabled: true + }, + { + value: "option-4", + label: "Option 4", + separator: true + }, + { + value: "option-5", + label: "Option 5", + item: { + anyKey1: "custom-value", + anyKey2: 2 + } + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "option-1", + label: "Option 1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + value: "option-2", + label: "Option 2", + disabled: false, + selected: false, // `selected` is overwritten by the presenter + separator: false, + item: null + }, + { + value: "option-3", + label: "Option 3", + disabled: true, + selected: false, + separator: false, + item: null + }, + { + value: "option-4", + label: "Option 4", + disabled: false, + selected: false, + separator: true, + item: null + }, + { + value: "option-5", + label: "Option 5", + disabled: false, + selected: false, + separator: false, + item: { + anyKey1: "custom-value", + anyKey2: 2 + } + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + // with `options` and `value` + { + presenter.init({ onValueChange, options: ["Option 1", "Option 2"], value: "Option 1" }); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + value: "Option 2", + label: "Option 2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + { + // default: no params + presenter.init({ onValueChange }); + expect(presenter.vm.optionsListVm.options).toEqual([]); + expect(presenter.vm.optionsListVm.emptyMessage).toEqual("No results."); + expect(presenter.vm.optionsListVm.loadingMessage).toEqual("Loading..."); + expect(presenter.vm.optionsListVm.isOpen).toEqual(false); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(true); + } + }); + + it("should change the `options` state and call `onValueChange` callback when `setSelectedOption` is called", () => { + presenter.init({ + onValueChange, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + + presenter.setSelectedOption("option-2"); + expect(onValueChange).toHaveBeenCalledWith("option-2"); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + }); + + it("should set the internal `inputValue` when `setInputValue` is called", () => { + presenter.init({ + onValueChange + }); + + presenter.searchOption("value"); + expect(presenter.vm.inputVm.value).toEqual("value"); + expect(presenter.vm.inputVm.displayResetAction).toEqual(true); + }); + + it("should set the option as `selected` when the presenter is initialized with a value", () => { + presenter.init({ + onValueChange, + value: "option-1", + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.inputVm.value).toEqual("Option 1"); + }); + + it("should reset the internal `options` state call `onValueChange` and `onValueReset` callbacks when `resetValue` is called", () => { + const onValueReset = jest.fn(); + presenter.init({ + onValueChange, + onValueReset, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + + presenter.setSelectedOption("option-1"); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.inputVm.value).toEqual("Option 1"); + + presenter.resetSelectedOption(); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.inputVm.value).toEqual(""); + + expect(onValueChange).toHaveBeenCalledWith(""); + expect(onValueReset).toHaveBeenCalled(); + }); + + it("should change `optionsListVm` and call `onOpenChange` when `setListOpenState` is called", () => { + const onOpenChange = jest.fn(); + + // let's open it + presenter.init({ onValueChange, onOpenChange }); + presenter.setListOpenState(true); + expect(presenter.vm.optionsListVm.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true); + + // let's close it + presenter.setListOpenState(false); + expect(presenter.vm.optionsListVm.isOpen).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("should not display the reset action if `displayResetAction` is set to `false` and option is selected", () => { + presenter.init({ + onValueChange, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ], + displayResetAction: false + }); + + presenter.setSelectedOption("option-2"); + expect(onValueChange).toHaveBeenCalledWith("option-2"); + // `displayResetAction` is set to `false` and option is selected + expect(presenter.vm.inputVm.displayResetAction).toEqual(false); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + }); +}); diff --git a/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.ts b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.ts new file mode 100644 index 00000000000..e81c3dc4308 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/presenters/AutoCompletePresenter.ts @@ -0,0 +1,116 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { IAutoCompleteInputPresenter } from "./AutoCompleteInputPresenter"; +import { IAutoCompleteListOptionsPresenter } from "./AutoCompleteListOptionsPresenter"; +import { AutoCompleteOption } from "../domains"; + +interface AutoCompletePresenterParams { + displayResetAction?: boolean; + emptyMessage?: any; + loadingMessage?: any; + onOpenChange?: (open: boolean) => void; + onValueChange: (value: string) => void; + onValueReset?: () => void; + options?: AutoCompleteOption[]; + placeholder?: string; + value?: string; +} + +interface IAutoCompletePresenterParams { + vm: { + inputVm: IAutoCompleteInputPresenter["vm"]; + optionsListVm: IAutoCompleteListOptionsPresenter["vm"]; + }; + init: (params: AutoCompletePresenterParams) => void; + setListOpenState: (open: boolean) => void; + setSelectedOption: (value: string) => void; + searchOption: (value: string) => void; + resetSelectedOption: () => void; +} + +class AutoCompletePresenter implements IAutoCompletePresenterParams { + private params?: AutoCompletePresenterParams = undefined; + private inputPresenter: IAutoCompleteInputPresenter; + private optionsListPresenter: IAutoCompleteListOptionsPresenter; + + constructor( + inputPresenter: IAutoCompleteInputPresenter, + optionsListPresenter: IAutoCompleteListOptionsPresenter + ) { + this.inputPresenter = inputPresenter; + this.optionsListPresenter = optionsListPresenter; + makeAutoObservable(this); + } + + init(params: AutoCompletePresenterParams) { + this.params = params; + + const listOptions = this.getListOptions(params.options, params.value); + this.optionsListPresenter.init({ + options: listOptions, + emptyMessage: params.emptyMessage, + loadingMessage: params.loadingMessage + }); + + const selected = this.getSelectedOption(); + this.inputPresenter.init({ + value: selected?.label ?? "", + placeholder: params.placeholder, + displayResetAction: params.displayResetAction + }); + } + + get vm() { + return { + inputVm: this.inputPresenter.vm, + optionsListVm: this.optionsListPresenter.vm + }; + } + + public setListOpenState = (open: boolean) => { + this.optionsListPresenter.setListOpenState(open); + this.params?.onOpenChange?.(open); + }; + + public searchOption = (value: string) => { + this.inputPresenter.setValue(value); + }; + + public setSelectedOption = (value: string) => { + this.resetSelectedOption(); + this.optionsListPresenter.setSelectedOption(value); + const option = this.getSelectedOption(); + + if (option) { + this.searchOption(option.label); + } + + this.params?.onValueChange?.(value); + }; + + public resetSelectedOption = () => { + this.optionsListPresenter.resetSelectedOption(); + this.inputPresenter.resetValue(); + + this.params?.onValueChange?.(""); + this.params?.onValueReset?.(); + }; + + private getListOptions(options: AutoCompleteOption[] = [], value?: string): CommandOption[] { + return options.map(option => { + const commandOption = + typeof option === "string" + ? CommandOption.createFromString(option) + : CommandOption.create(option); + + commandOption.selected = commandOption.value === value; + return commandOption; + }); + } + + private getSelectedOption() { + return this.vm.optionsListVm.options.find(option => option.selected); + } +} + +export { AutoCompletePresenter, type AutoCompletePresenterParams }; diff --git a/packages/admin-ui/src/AutoComplete/primitives/presenters/index.ts b/packages/admin-ui/src/AutoComplete/primitives/presenters/index.ts new file mode 100644 index 00000000000..c8eca1661c4 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/presenters/index.ts @@ -0,0 +1,3 @@ +export * from "./AutoCompletePresenter"; +export * from "./AutoCompleteInputPresenter"; +export * from "./AutoCompleteListOptionsPresenter"; diff --git a/packages/admin-ui/src/AutoComplete/primitives/useAutoComplete.ts b/packages/admin-ui/src/AutoComplete/primitives/useAutoComplete.ts new file mode 100644 index 00000000000..2184fa90df7 --- /dev/null +++ b/packages/admin-ui/src/AutoComplete/primitives/useAutoComplete.ts @@ -0,0 +1,64 @@ +import { useEffect, useMemo, useState } from "react"; +import { autorun } from "mobx"; +import { AutoCompletePrimitiveProps } from "./AutoCompletePrimitive"; +import { + AutoCompletePresenter, + AutoCompletePresenterParams, + AutoCompleteInputPresenter, + AutoCompleteListOptionsPresenter +} from "./presenters"; + +export const useAutoComplete = (props: AutoCompletePrimitiveProps) => { + const params: AutoCompletePresenterParams = useMemo( + () => ({ + options: props.options, + value: props.value, + placeholder: props.placeholder, + emptyMessage: props.emptyMessage, + loadingMessage: props.loadingMessage, + onOpenChange: props.onOpenChange, + onValueChange: props.onValueChange, + onValueReset: props.onValueReset, + displayResetAction: props.displayResetAction + }), + [ + props.options, + props.value, + props.placeholder, + props.emptyMessage, + props.loadingMessage, + props.onOpenChange, + props.onValueChange, + props.onValueReset, + props.displayResetAction + ] + ); + + const presenter = useMemo(() => { + const inputPresenter = new AutoCompleteInputPresenter(); + const optionsListPresenter = new AutoCompleteListOptionsPresenter(); + const presenter = new AutoCompletePresenter(inputPresenter, optionsListPresenter); + presenter.init(params); + return presenter; + }, []); + + const [vm, setVm] = useState(presenter.vm); + + useEffect(() => { + presenter.init(params); + }, [params, presenter]); + + useEffect(() => { + return autorun(() => { + setVm(presenter.vm); + }); + }, [presenter]); + + return { + vm, + setSelectedOption: presenter.setSelectedOption, + searchOption: presenter.searchOption, + resetSelectedOption: presenter.resetSelectedOption, + setListOpenState: presenter.setListOpenState + }; +}; diff --git a/packages/admin-ui/src/Command/Command.tsx b/packages/admin-ui/src/Command/Command.tsx new file mode 100644 index 00000000000..b04c1ee8bc0 --- /dev/null +++ b/packages/admin-ui/src/Command/Command.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn, withStaticProps } from "~/utils"; +import { Empty, Group, Input, Item, List, Loading, Separator } from "./components"; + +type CommandProps = React.ComponentPropsWithoutRef; + +const CommandBase = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +); + +const Command = withStaticProps(CommandBase, { + Empty, + Group, + Input, + Item, + List, + Loading, + Separator +}); + +export { Command, type CommandProps }; diff --git a/packages/admin-ui/src/Command/components/Empty.tsx b/packages/admin-ui/src/Command/components/Empty.tsx new file mode 100644 index 00000000000..9c4ac8ae52b --- /dev/null +++ b/packages/admin-ui/src/Command/components/Empty.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; + +type EmptyProps = React.ComponentPropsWithoutRef; + +const Empty = (props: EmptyProps) => ( + +); + +export { Empty, type EmptyProps }; diff --git a/packages/admin-ui/src/Command/components/Group.tsx b/packages/admin-ui/src/Command/components/Group.tsx new file mode 100644 index 00000000000..a52245207f9 --- /dev/null +++ b/packages/admin-ui/src/Command/components/Group.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn } from "~/utils"; + +type GroupProps = React.ComponentPropsWithoutRef; + +const Group = ({ className, ...props }: GroupProps) => ( + +); + +export { Group, type GroupProps }; diff --git a/packages/admin-ui/src/Command/components/Input.tsx b/packages/admin-ui/src/Command/components/Input.tsx new file mode 100644 index 00000000000..343fd1646e5 --- /dev/null +++ b/packages/admin-ui/src/Command/components/Input.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { InputPrimitive, InputPrimitiveProps } from "~/Input"; + +type InputProps = Omit, "size"> & + InputPrimitiveProps & { + inputElement?: React.ReactNode; + }; + +const Input = ({ inputElement, size, ...props }: InputProps) => { + return ( + + {inputElement ?? } + + ); +}; + +export { Input, type InputProps }; diff --git a/packages/admin-ui/src/Command/components/Item.tsx b/packages/admin-ui/src/Command/components/Item.tsx new file mode 100644 index 00000000000..1332752516e --- /dev/null +++ b/packages/admin-ui/src/Command/components/Item.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { ReactComponent as Check } from "@material-design-icons/svg/outlined/check.svg"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn, cva, type VariantProps } from "~/utils"; + +const commandItemVariants = cva( + [ + "flex items-center justify-between gap-sm-extra cursor-default select-none rounded-sm p-sm mx-sm text-md outline-none overflow-hidden", + "bg-neutral-base text-neutral-primary fill-neutral-xstrong", + "data-[disabled=true]:text-neutral-disabled data-[disabled=true]:cursor-not-allowed", + "data-[selected=true]:bg-neutral-dimmed" + ], + { + variants: { + selected: { + true: "font-semibold bg-neutral-dimmed" + } + } + } +); + +interface ItemProps + extends React.ComponentPropsWithoutRef, + VariantProps { + selected?: boolean; +} + +const Item = ({ className, children, selected, ...props }: ItemProps) => ( + + {children} + {selected ? : null} + +); + +export { Item, type ItemProps }; diff --git a/packages/admin-ui/src/Command/components/List.tsx b/packages/admin-ui/src/Command/components/List.tsx new file mode 100644 index 00000000000..9a8ddb5440b --- /dev/null +++ b/packages/admin-ui/src/Command/components/List.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn } from "~/utils"; + +type ListProps = React.ComponentPropsWithoutRef; + +const List = ({ className, ...props }: ListProps) => { + return ( + + ); +}; + +export { List, type ListProps }; diff --git a/packages/admin-ui/src/Command/components/Loading.tsx b/packages/admin-ui/src/Command/components/Loading.tsx new file mode 100644 index 00000000000..68da2b52e4f --- /dev/null +++ b/packages/admin-ui/src/Command/components/Loading.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; + +type LoadingProps = React.ComponentPropsWithoutRef; + +const Loading = (props: LoadingProps) => ( + +); + +export { Loading, type LoadingProps }; diff --git a/packages/admin-ui/src/Command/components/Separator.tsx b/packages/admin-ui/src/Command/components/Separator.tsx new file mode 100644 index 00000000000..a484f683dc7 --- /dev/null +++ b/packages/admin-ui/src/Command/components/Separator.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { Separator as SeparatorPrimitive } from "~/Separator"; + +type SeparatorProps = React.ComponentPropsWithoutRef; + +const Separator = (props: SeparatorProps) => ( + + + +); + +export { Separator, type SeparatorProps }; diff --git a/packages/admin-ui/src/Command/components/index.ts b/packages/admin-ui/src/Command/components/index.ts new file mode 100644 index 00000000000..51e28ee4fa8 --- /dev/null +++ b/packages/admin-ui/src/Command/components/index.ts @@ -0,0 +1,7 @@ +export * from "./Empty"; +export * from "./Group"; +export * from "./Input"; +export * from "./Item"; +export * from "./List"; +export * from "./Loading"; +export * from "./Separator"; diff --git a/packages/admin-ui/src/Command/domain/CommandOption.ts b/packages/admin-ui/src/Command/domain/CommandOption.ts new file mode 100644 index 00000000000..b8a396d8d58 --- /dev/null +++ b/packages/admin-ui/src/Command/domain/CommandOption.ts @@ -0,0 +1,80 @@ +import { CommandOptionDto } from "./CommandOptionDto"; + +export class CommandOption { + private readonly _label: string; + private readonly _value: string; + private _disabled: boolean; + private readonly _separator: boolean; + private readonly _item: any | null; + private _selected: boolean; + + protected constructor(data: { + label: string; + value: string; + disabled: boolean; + selected: boolean; + separator: boolean; + item: any | null; + }) { + this._label = data.label; + this._value = data.value; + this._disabled = data.disabled; + this._selected = data.selected; + this._separator = data.separator; + this._item = data.item; + } + + static create(data: CommandOptionDto) { + return new CommandOption({ + label: data.label, + value: data.value, + disabled: data.disabled ?? false, + selected: false, + separator: data.separator ?? false, + item: data.item ?? null + }); + } + + static createFromString(value: string) { + return new CommandOption({ + label: value, + value: value, + disabled: false, + selected: false, + separator: false, + item: null + }); + } + + get label() { + return this._label; + } + + get value() { + return this._value; + } + + get disabled() { + return this._disabled; + } + + set disabled(selected: boolean) { + this._disabled = selected; + } + + get selected() { + return this._selected; + } + + set selected(selected: boolean) { + this._selected = selected; + } + + get separator() { + return this._separator; + } + + get item() { + return this._item; + } +} diff --git a/packages/admin-ui/src/Command/domain/CommandOptionDto.ts b/packages/admin-ui/src/Command/domain/CommandOptionDto.ts new file mode 100644 index 00000000000..2ee4888aa3d --- /dev/null +++ b/packages/admin-ui/src/Command/domain/CommandOptionDto.ts @@ -0,0 +1,7 @@ +export interface CommandOptionDto { + label: string; + value: string; + disabled?: boolean; + separator?: boolean; + item?: any; +} diff --git a/packages/admin-ui/src/Command/domain/CommandOptionFormatted.ts b/packages/admin-ui/src/Command/domain/CommandOptionFormatted.ts new file mode 100644 index 00000000000..b1a5a498888 --- /dev/null +++ b/packages/admin-ui/src/Command/domain/CommandOptionFormatted.ts @@ -0,0 +1,8 @@ +export interface CommandOptionFormatted { + label: string; + value: string; + disabled: boolean; + selected: boolean; + separator: boolean; + item: any | null; +} diff --git a/packages/admin-ui/src/Command/domain/CommandOptionFormatter.ts b/packages/admin-ui/src/Command/domain/CommandOptionFormatter.ts new file mode 100644 index 00000000000..44dae193163 --- /dev/null +++ b/packages/admin-ui/src/Command/domain/CommandOptionFormatter.ts @@ -0,0 +1,15 @@ +import { CommandOption } from "./CommandOption"; +import { CommandOptionFormatted } from "./CommandOptionFormatted"; + +export class CommandOptionFormatter { + static format(option: CommandOption): CommandOptionFormatted { + return { + label: option.label, + value: option.value, + disabled: option.disabled, + selected: option.selected, + separator: option.separator, + item: option.item + }; + } +} diff --git a/packages/admin-ui/src/Command/domain/index.ts b/packages/admin-ui/src/Command/domain/index.ts new file mode 100644 index 00000000000..4f4e3acc23b --- /dev/null +++ b/packages/admin-ui/src/Command/domain/index.ts @@ -0,0 +1,4 @@ +export * from "./CommandOption"; +export * from "./CommandOptionDto"; +export * from "./CommandOptionFormatted"; +export * from "./CommandOptionFormatter"; diff --git a/packages/admin-ui/src/Command/index.ts b/packages/admin-ui/src/Command/index.ts new file mode 100644 index 00000000000..95f2837c8c4 --- /dev/null +++ b/packages/admin-ui/src/Command/index.ts @@ -0,0 +1,2 @@ +export * from "./Command"; +export * from "./domain"; diff --git a/packages/admin-ui/src/DropdownMenu/components/DropdownMenuSeparator.tsx b/packages/admin-ui/src/DropdownMenu/components/DropdownMenuSeparator.tsx index f9ea6d7f302..5602872b531 100644 --- a/packages/admin-ui/src/DropdownMenu/components/DropdownMenuSeparator.tsx +++ b/packages/admin-ui/src/DropdownMenu/components/DropdownMenuSeparator.tsx @@ -2,14 +2,9 @@ import * as React from "react"; import { Separator, type SeparatorProps } from "~/Separator"; import { makeDecoratable } from "~/utils"; -const DropdownMenuSeparatorBase = React.forwardRef< - React.ElementRef, - SeparatorProps ->((props, ref) => { - return ; -}); - -DropdownMenuSeparatorBase.displayName = Separator.displayName; +const DropdownMenuSeparatorBase = (props: SeparatorProps) => { + return ; +}; export const DropdownMenuSeparator = makeDecoratable( "DropdownMenuSeparator", diff --git a/packages/admin-ui/src/Input/InputPrimitive.tsx b/packages/admin-ui/src/Input/InputPrimitive.tsx index ffd53398e6d..8764f040e58 100644 --- a/packages/admin-ui/src/Input/InputPrimitive.tsx +++ b/packages/admin-ui/src/Input/InputPrimitive.tsx @@ -1,18 +1,18 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Icon as BaseIcon } from "~/Icon"; -import { cn, makeDecoratable } from "~/utils"; +import { cn } from "~/utils"; /** * Icon */ -const iconVariants = cva("absolute transform top-1/2 -translate-y-1/2 fill-neutral-xstrong", { +const inputIconVariants = cva("absolute fill-neutral-xstrong", { variants: { // Define dummy variants to be used in combination with `compoundVariants` below. inputSize: { - md: "", - lg: "", - xl: "" + md: "top-sm", + lg: "top-sm-extra", + xl: "top-md" }, position: { start: "", @@ -61,14 +61,15 @@ const iconVariants = cva("absolute transform top-1/2 -translate-y-1/2 fill-neutr ] }); -interface IconWrapperProps extends VariantProps { +interface InputIconProps + extends React.HTMLAttributes, + VariantProps { icon: React.ReactElement; - disabled?: boolean; } -const Icon = ({ icon, disabled, position, inputSize }: IconWrapperProps) => { +const InputIcon = ({ icon, disabled, position, inputSize, className }: InputIconProps) => { return ( -
+
{React.cloneElement(icon, { ...icon.props, size: inputSize === "xl" ? "lg" : "sm" // Map icon size based on the input size. @@ -79,12 +80,16 @@ const Icon = ({ icon, disabled, position, inputSize }: IconWrapperProps) => { /** * Input + * + * We support both `disabled` and `data-disabled` as well as `focused` and `data-focused` variants + * because these variants can be used by both input and div elements. The last one is used by `MultiAutocomplete` component, + * where the `inputVariants` is used to style a div that wraps multiple elements (input, Tags, icons, etc.) */ const inputVariants = cva( [ - "w-full border-sm text-md", + "w-full border-sm text-md peer", "focus-visible:outline-none", - "disabled:cursor-not-allowed", + "disabled:cursor-not-allowed data-[disabled=true]:cursor-not-allowed", "file:bg-transparent file:border-none file:text-sm file:font-semibold" ], { @@ -108,19 +113,25 @@ const inputVariants = cva( "bg-neutral-base border-neutral-muted text-neutral-strong placeholder:text-neutral-dimmed", "hover:border-neutral-strong", "focus:border-neutral-black", - "disabled:bg-neutral-disabled disabled:border-neutral-dimmed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled" + "data-[focused=true]:border-neutral-black", + "disabled:bg-neutral-disabled disabled:border-neutral-dimmed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled", + "data-[disabled=true]:bg-neutral-disabled data-[disabled=true]:border-neutral-dimmed data-[disabled=true]:text-neutral-disabled data-[disabled=true]:placeholder:text-neutral-disabled" ], secondary: [ "bg-neutral-light border-neutral-subtle text-neutral-strong placeholder:text-neutral-dimmed", "hover:bg-neutral-dimmed", "focus:bg-neutral-base focus:border-neutral-black", - "disabled:bg-neutral-disabled disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled" + "data-[focused=true]:bg-neutral-base data-[focused=true]:border-neutral-black", + "disabled:bg-neutral-disabled disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled", + "data-[disabled=true]:bg-neutral-disabled data-[disabled=true]:text-neutral-disabled data-[disabled=true]:placeholder:text-neutral-disabled" ], ghost: [ "bg-transparent border-transparent text-neutral-strong placeholder:text-neutral-dimmed", "hover:bg-neutral-dimmed/95", "focus:bg-neutral-base focus:border-neutral-black", - "disabled:bg-transparent disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled" + "data-[focused=true]:bg-neutral-base data-[focused=true]:border-neutral-black", + "disabled:bg-transparent disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled", + "data-[disabled=true]:bg-transparent data-[disabled=true]:text-neutral-disabled data-[disabled=true]:placeholder:text-neutral-disabled" ] }, iconPosition: { @@ -136,7 +147,9 @@ const inputVariants = cva( "border-destructive-default", "hover:border-destructive-default", "focus:border-destructive-default", - "disabled:border-destructive-default" + "data-[focused=true]:border-destructive-default", + "disabled:border-destructive-default", + "data-[disabled=true]:border-destructive-default" ] } }, @@ -186,7 +199,9 @@ const inputVariants = cva( "border-destructive-subtle bg-destructive-subtle", "hover:border-destructive-subtle", "focus:border-destructive-subtle", - "disabled:bg-destructive-subtle disabled:border-destructive-subtle" + "data-[focused=true]:border-destructive-subtle", + "disabled:bg-destructive-subtle disabled:border-destructive-subtle", + "data-[disabled=true]:bg-destructive-subtle data-[disabled=true]:border-destructive-subtle" ] } ], @@ -203,6 +218,7 @@ interface InputPrimitiveProps startIcon?: React.ReactElement | React.ReactElement; endIcon?: React.ReactElement | React.ReactElement; maxLength?: React.InputHTMLAttributes["size"]; + inputRef?: React.Ref; } const getIconPosition = ( @@ -221,39 +237,49 @@ const getIconPosition = ( return; }; -const DecoratableInputPrimitive = React.forwardRef( - ( - { className, disabled, invalid, startIcon, maxLength, size, endIcon, variant, ...props }, - ref - ) => { - const iconPosition = getIconPosition(startIcon, endIcon); +const InputPrimitive = ({ + className, + disabled, + invalid, + startIcon, + maxLength, + size, + endIcon, + variant, + inputRef, + ...props +}: InputPrimitiveProps) => { + const iconPosition = getIconPosition(startIcon, endIcon); - return ( -
- {startIcon && ( - - )} - + {startIcon && ( + - {endIcon && ( - - )} -
- ); - } -); -DecoratableInputPrimitive.displayName = "InputPrimitive"; - -const InputPrimitive = makeDecoratable("InputPrimitive", DecoratableInputPrimitive); + )} + + {endIcon && ( + + )} +
+ ); +}; -export { InputPrimitive, type InputPrimitiveProps }; +export { + InputIcon, + InputPrimitive, + getIconPosition, + inputVariants, + type InputIconProps, + type InputPrimitiveProps +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.stories.tsx b/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.stories.tsx new file mode 100644 index 00000000000..6c9b092809d --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.stories.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { MultiAutoComplete } from "./MultiAutoComplete"; + +const meta: Meta = { + title: "Components/Form/Multi AutoComplete", + component: MultiAutoComplete, + tags: ["autodocs"], + argTypes: { + onValuesChange: { action: "onValuesChange" }, + onOpenChange: { action: "onOpenChange" } + }, + parameters: { + layout: "padded" + }, + render: args => { + const [values, setValues] = useState(args.values); + return ; + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + "Eastern Standard Time (EST)", + "Central Standard Time (CST)", + "Pacific Standard Time (PST)", + "Greenwich Mean Time (GMT)", + "Central European Time (CET)", + "Central Africa Time (CAT)", + "India Standard Time (IST)", + "China Standard Time (CST)", + "Japan Standard Time (JST)", + "Australian Western Standard Time (AWST)", + "New Zealand Standard Time (NZST)", + "Fiji Time (FJT)", + "Argentina Time (ART)", + "Bolivia Time (BOT)", + "Brasilia Time (BRT)" + ] + } +}; + +export const WithLabel: Story = { + args: { + ...Default.args, + label: "Any field label" + } +}; + +export const WithRequiredLabel: Story = { + args: { + ...Default.args, + label: "Any field label", + required: true + } +}; + +export const WithDescription: Story = { + args: { + ...Default.args, + description: "Provide the required information for processing your request." + } +}; + +export const WithNotes: Story = { + args: { + ...Default.args, + note: "Note: Ensure your selection or input is accurate before proceeding." + } +}; + +export const WithErrors: Story = { + args: { + ...Default.args, + validation: { + isValid: false, + message: "This field is required." + } + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + label: "Any field label", + disabled: true + } +}; + +export const FullExample: Story = { + args: { + ...Default.args, + label: "Any field label", + required: true, + description: "Provide the required information for processing your request.", + note: "Note: Ensure your selection or input is accurate before proceeding.", + validation: { + isValid: false, + message: "This field is required." + } + } +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.tsx b/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.tsx new file mode 100644 index 00000000000..8deec0d44ba --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/MultiAutoComplete.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from "react"; +import { makeDecoratable } from "~/utils"; +import { MultiAutoCompletePrimitive, MultiAutoCompletePrimitiveProps } from "./primitives"; +import { + FormComponentDescription, + FormComponentErrorMessage, + FormComponentLabel, + FormComponentNote, + FormComponentProps +} from "~/FormComponent"; + +type MultiAutoCompleteProps = MultiAutoCompletePrimitiveProps & FormComponentProps; + +const DecoratableMultiAutoComplete = ({ + label, + description, + note, + required, + disabled, + validation, + ...props +}: MultiAutoCompleteProps) => { + const { isValid: validationIsValid, message: validationMessage } = validation || {}; + const invalid = useMemo(() => validationIsValid === false, [validationIsValid]); + + return ( +
+ + + + + +
+ ); +}; + +const MultiAutoComplete = makeDecoratable("MultiAutoComplete", DecoratableMultiAutoComplete); + +export { MultiAutoComplete }; diff --git a/packages/admin-ui/src/MultiAutoComplete/index.ts b/packages/admin-ui/src/MultiAutoComplete/index.ts new file mode 100644 index 00000000000..311a209cede --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/index.ts @@ -0,0 +1 @@ +export * from "./MultiAutoComplete"; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.stories.tsx b/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.stories.tsx new file mode 100644 index 00000000000..abe7eaaf2b4 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.stories.tsx @@ -0,0 +1,503 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { ReactComponent as SearchIcon } from "@material-design-icons/svg/outlined/search.svg"; +import { MultiAutoCompletePrimitive } from "./MultiAutoCompletePrimitive"; +import { Button } from "~/Button"; +import { Icon } from "~/Icon"; +import { Tag } from "~/Tag"; + +const meta: Meta = { + title: "Components/Form Primitives/Multi Autocomplete", + component: MultiAutoCompletePrimitive, + tags: ["autodocs"], + parameters: { + layout: "padded" + }, + argTypes: { + onValuesChange: { action: "onValuesChange" }, + onOpenChange: { action: "onOpenChange" }, + disabled: { + control: { + type: "boolean" + } + } + }, + render: args => { + const [values, setValues] = useState(args.values); + return ( +
+ +
Current selected values: {values}
+
+ ); + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + "Eastern Standard Time (EST)", + "Central Standard Time (CST)", + "Pacific Standard Time (PST)", + "Greenwich Mean Time (GMT)", + "Central European Time (CET)", + "Central Africa Time (CAT)", + "India Standard Time (IST)", + "China Standard Time (CST)", + "Japan Standard Time (JST)", + "Australian Western Standard Time (AWST)", + "New Zealand Standard Time (NZST)", + "Fiji Time (FJT)", + "Argentina Time (ART)", + "Bolivia Time (BOT)", + "Brasilia Time (BRT)" + ] + } +}; + +export const MediumSize: Story = { + args: { + ...Default.args, + size: "md" + } +}; + +export const LargeSize: Story = { + args: { + ...Default.args, + size: "lg" + } +}; + +export const ExtraLargeSize: Story = { + args: { + ...Default.args, + size: "xl" + } +}; + +export const WithStartIcon: Story = { + args: { + ...Default.args, + startIcon: } /> + } +}; + +export const WithoutResetAction: Story = { + args: { + ...Default.args, + displayResetAction: false + } +}; + +export const PrimaryVariant: Story = { + args: { + ...Default.args, + variant: "primary" + } +}; + +export const PrimaryVariantDisabled: Story = { + args: { + ...PrimaryVariant.args, + disabled: true + } +}; + +export const PrimaryVariantInvalid: Story = { + args: { + ...PrimaryVariant.args, + invalid: true + } +}; + +export const SecondaryVariant: Story = { + args: { + variant: "secondary", + placeholder: "Custom placeholder" + } +}; + +export const SecondaryVariantDisabled: Story = { + args: { + ...SecondaryVariant.args, + disabled: true + } +}; + +export const SecondaryVariantInvalid: Story = { + args: { + ...SecondaryVariant.args, + invalid: true + } +}; + +export const GhostVariant: Story = { + args: { + variant: "ghost", + placeholder: "Custom placeholder" + } +}; + +export const GhostVariantDisabled: Story = { + args: { + ...GhostVariant.args, + disabled: true + } +}; + +export const GhostVariantInvalid: Story = { + args: { + ...GhostVariant.args, + invalid: true + } +}; + +export const WithPredefinedValue: Story = { + args: { + ...Default.args, + values: ["Eastern Standard Time (EST)"] + } +}; + +export const WithCustomPlaceholder: Story = { + args: { + ...Default.args, + placeholder: "Custom placeholder" + } +}; + +export const WithCustomEmptyMessage: Story = { + args: { + ...Default.args, + emptyMessage: "Custom empty message" + } +}; + +export const WithAllowFreeInput: Story = { + args: { + ...Default.args, + allowFreeInput: true + } +}; + +export const WithUniqueValues: Story = { + args: { + ...Default.args, + uniqueValues: true, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst" } + ] + } +}; + +export const WithFormattedOptions: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst" }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithCustomOptionRenderer: Story = { + args: { + ...Default.args, + options: [ + { + label: "Eastern Standard Time (EST)", + value: "est", + item: { + name: "Eastern Standard Time (EST)", + time_difference: "-5:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ", + value: "est" + } + }, + { + label: "Central Standard Time (CST)", + value: "cst", + item: { + name: "Central Standard Time (CST)", + time_difference: "-6:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ", + value: "cst" + } + }, + { + label: "Pacific Standard Time (PST)", + value: "pst", + item: { + name: "Pacific Standard Time (PST)", + time_difference: "-8:00", + flag: "๐Ÿ‡บ๐Ÿ‡ธ", + value: "pst" + } + }, + { + label: "Greenwich Mean Time (GMT)", + value: "gmt", + item: { + name: "Greenwich Mean Time (GMT)", + time_difference: "ยฑ0:00", + flag: "๐Ÿ‡ฌ๐Ÿ‡ง", + value: "gmt" + } + }, + { + label: "Central European Time (CET)", + value: "cet", + item: { + name: "Central European Time (CET)", + time_difference: "+1:00", + flag: "๐Ÿ‡ช๐Ÿ‡บ", + value: "cet" + } + }, + { + label: "Central Africa Time (CAT)", + value: "cat", + item: { + name: "Central Africa Time (CAT)", + time_difference: "+2:00", + flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ", + value: "cat" + } + }, + { + label: "India Standard Time (IST)", + value: "ist", + item: { + name: "India Standard Time (IST)", + time_difference: "+5:30", + flag: "๐Ÿ‡ฎ๐Ÿ‡ณ", + value: "ist" + } + }, + { + label: "China Standard Time (CST)", + value: "cst_china", + item: { + name: "China Standard Time (CST)", + time_difference: "+8:00", + flag: "๐Ÿ‡จ๐Ÿ‡ณ", + value: "cst_china" + } + }, + { + label: "Japan Standard Time (JST)", + value: "jst", + item: { + name: "Japan Standard Time (JST)", + time_difference: "+9:00", + flag: "๐Ÿ‡ฏ๐Ÿ‡ต", + value: "jst" + } + }, + { + label: "Australian Western Standard Time (AWST)", + value: "awst", + item: { + name: "Australian Western Standard Time (AWST)", + time_difference: "+8:00", + flag: "๐Ÿ‡ฆ๐Ÿ‡บ", + value: "awst" + } + }, + { + label: "New Zealand Standard Time (NZST)", + value: "nzst", + item: { + name: "New Zealand Standard Time (NZST)", + time_difference: "+12:00", + flag: "๐Ÿ‡ณ๐Ÿ‡ฟ", + value: "nzst" + } + }, + { + label: "Fiji Time (FJT)", + value: "fjt", + item: { + name: "Fiji Time (FJT)", + time_difference: "+12:00", + flag: "๐Ÿ‡ซ๐Ÿ‡ฏ", + value: "fjt" + } + }, + { + label: "Argentina Time (ART)", + value: "art", + item: { + name: "Argentina Time (ART)", + time_difference: "-3:00", + flag: "๐Ÿ‡ฆ๐Ÿ‡ท", + value: "art" + } + }, + { + label: "Bolivia Time (BOT)", + value: "bot", + item: { + name: "Bolivia Time (BOT)", + time_difference: "-4:00", + flag: "๐Ÿ‡ง๐Ÿ‡ด", + value: "bot" + } + }, + { + label: "Brasilia Time (BRT)", + value: "brt", + item: { + name: "Brasilia Time (BRT)", + time_difference: "-3:00", + flag: "๐Ÿ‡ง๐Ÿ‡ท", + value: "brt" + } + } + ], + optionRenderer: item => { + return ( +
+
+ {item.flag} + {item.name} +
+
{item.time_difference}
+
+ ); + } + } +}; + +export const WithCustomSelectedOptionRenderer: Story = { + args: { + ...WithCustomOptionRenderer.args + }, + render: args => { + const [values, setValues] = useState(args.values); + const selectedOptionRenderer = (item: any) => { + return ( + + {item.flag} {item.name} + + } + onDismiss={() => setValues(values.filter(value => value !== item.value))} + /> + ); + }; + return ( +
+ +
Current selected values: {values}
+
+ ); + } +}; + +export const WithSeparators: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst", separator: true }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat", separator: true }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst", separator: true }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt", separator: true }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithDisabledOptions: Story = { + args: { + options: [ + { label: "Eastern Standard Time (EST)", value: "est", disabled: true }, + { label: "Central Standard Time (CST)", value: "cst", disabled: true }, + { label: "Pacific Standard Time (PST)", value: "pst", disabled: true }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + } +}; + +export const WithExternalValueControl: Story = { + args: { + ...Default.args, + options: [ + { label: "Eastern Standard Time (EST)", value: "est" }, + { label: "Central Standard Time (CST)", value: "cst" }, + { label: "Pacific Standard Time (PST)", value: "pst" }, + { label: "Greenwich Mean Time (GMT)", value: "gmt" }, + { label: "Central European Time (CET)", value: "cet" }, + { label: "Central Africa Time (CAT)", value: "cat" }, + { label: "India Standard Time (IST)", value: "ist" }, + { label: "China Standard Time (CST)", value: "cst_china" }, + { label: "Japan Standard Time (JST)", value: "jst" }, + { label: "Australian Western Standard Time (AWST)", value: "awst" }, + { label: "New Zealand Standard Time (NZST)", value: "nzst" }, + { label: "Fiji Time (FJT)", value: "fjt" }, + { label: "Argentina Time (ART)", value: "art" }, + { label: "Bolivia Time (BOT)", value: "bot" }, + { label: "Brasilia Time (BRT)", value: "brt" } + ] + }, + render: args => { + const [values, setValues] = useState(args.values); + return ( +
+ +
+
+
Current selected values: {values}
+
+ ); + } +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.tsx b/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.tsx new file mode 100644 index 00000000000..cd5f80bb2f8 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/MultiAutoCompletePrimitive.tsx @@ -0,0 +1,175 @@ +import React, { KeyboardEvent } from "react"; +import { Command } from "~/Command"; +import { Popover } from "~/Popover"; +import { InputPrimitiveProps } from "~/Input"; +import { useMultiAutoComplete } from "./useMultiAutoComplete"; +import { + MultiAutoCompleteInput, + MultiAutoCompleteInputIcons, + MultiAutoCompleteList +} from "./components"; +import { MultiAutoCompleteOption } from "./domains"; + +type MultiAutoCompletePrimitiveProps = Omit< + InputPrimitiveProps, + "endIcon" | "value" | "onValueChange" +> & { + /** + * Accessible label for the command menu. Not shown visibly. + */ + label?: string; + /** + * Allows free input of values not present in the options. + */ + allowFreeInput?: boolean; + /** + * Message to display when there are no options. + */ + emptyMessage?: React.ReactNode; + /** + * Indicates if the autocomplete is loading options. + */ + isLoading?: boolean; + /** + * Message to display while loading options. + */ + loadingMessage?: React.ReactNode; + /** + * Callback triggered when the open state changes. + */ + onOpenChange?: (open: boolean) => void; + /** + * Callback triggered when the values change. + */ + onValuesChange: (values: string[]) => void; + /** + * Callback triggered to reset the values. + */ + onValuesReset?: () => void; + /** + * Custom renderer for the options. + */ + optionRenderer?: (item: any, index: number) => React.ReactNode; + /** + * List of options for the autocomplete. + */ + options?: MultiAutoCompleteOption[]; + /** + * Custom renderer for the selected options. + */ + selectedOptionRenderer?: (item: any, index: number) => React.ReactNode; + /** + * Ensures that each value is unique. + */ + uniqueValues?: boolean; + /** + * Optional selected items. + */ + values: string[]; + /** + * Indicates if the reset action should be displayed. + */ + displayResetAction?: boolean; +}; + +const MultiAutoCompletePrimitive = (props: MultiAutoCompletePrimitiveProps) => { + const { + vm, + setListOpenState, + setSelectedOption, + searchOption, + removeSelectedOption, + resetSelectedOptions, + createOption + } = useMultiAutoComplete(props); + + const handleKeyDown = React.useCallback( + (event: KeyboardEvent) => { + if (props.disabled) { + return; + } + + if (!vm.optionsListVm.isOpen) { + setListOpenState(true); + } + + if (event.key.toLowerCase() === "escape") { + setListOpenState(false); + } + }, + [props.disabled, setListOpenState, setSelectedOption, vm.optionsListVm.isOpen] + ); + + const handleSelectOption = React.useCallback( + (value: string) => { + setSelectedOption(value); + setListOpenState(false); + }, + [setSelectedOption, setListOpenState] + ); + + const handleCreateOption = React.useCallback( + (value: string) => { + createOption(value); + setListOpenState(false); + }, + [createOption, setListOpenState] + ); + + return ( + setListOpenState(true)}> + + + + setListOpenState(false)} + openList={() => setListOpenState(true)} + variant={props.variant} + size={props.size} + invalid={props.invalid} + removeSelectedOption={removeSelectedOption} + selectedOptionRenderer={props.selectedOptionRenderer} + selectedOptions={vm.selectedOptionsVm.options} + disabled={props.disabled} + startIcon={props.startIcon} + endIcon={ + setListOpenState(!vm.optionsListVm.isOpen)} + /> + } + /> + + + + e.preventDefault()} + > + + + + + + ); +}; + +export { MultiAutoCompletePrimitive, type MultiAutoCompletePrimitiveProps }; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInput.tsx b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInput.tsx new file mode 100644 index 00000000000..5733ab8d82d --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInput.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { getIconPosition, InputIcon, InputPrimitiveProps, inputVariants } from "~/Input"; +import { Command, CommandOptionFormatted } from "~/Command"; +import { Tag } from "~/Tag"; +import { cn, cva, VariantProps } from "~/utils"; + +const multiAutoCompleteInputVariants = cva("relative placeholder:text-neutral-dimmed", { + variants: { + disabled: { + true: "cursor-not-allowed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled" + } + } +}); + +type MultiAutoCompleteInputProps = VariantProps & + InputPrimitiveProps & { + changeValue: (value: string) => void; + closeList: () => void; + openList: () => void; + placeholder: string; + removeSelectedOption: (value: string) => void; + selectedOptionRenderer?: (item: any, index: number) => React.ReactNode; + selectedOptions: CommandOptionFormatted[]; + value: string; + }; + +const MultiAutoCompleteInput = ({ + changeValue, + closeList, + disabled, + endIcon, + invalid, + openList, + placeholder, + removeSelectedOption, + selectedOptionRenderer, + selectedOptions, + size, + startIcon, + value, + variant, + className, + ...props +}: MultiAutoCompleteInputProps) => { + const [focused, setFocused] = React.useState(false); + const inputRef = React.useRef(null); + const iconPosition = getIconPosition(startIcon, endIcon); + + const renderSelectedOptions = React.useCallback( + (options: CommandOptionFormatted[]) => { + return options.map((option, index) => { + if (selectedOptionRenderer) { + if (!option.item) { + return null; + } + return selectedOptionRenderer.call(this, option.item, index); + } + + return ( + removeSelectedOption(option.value)} + /> + ); + }); + }, + [selectedOptionRenderer, removeSelectedOption] + ); + + return ( +
{ + if (disabled) { + return; + } + inputRef?.current?.focus(); + setFocused(true); + }} + data-disabled={disabled} + data-focused={focused} + > + {startIcon && ( + + )} +
+ {renderSelectedOptions(selectedOptions)} + { + setFocused(false); + closeList(); + }} + onFocus={() => { + setFocused(true); + openList(); + }} + inputElement={ + + } + /> +
+ {endIcon && ( + + )} +
+ ); +}; + +export { MultiAutoCompleteInput, type MultiAutoCompleteInputProps }; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInputIcons.tsx b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInputIcons.tsx new file mode 100644 index 00000000000..e64f5ccc67f --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteInputIcons.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { ReactComponent as Close } from "@material-design-icons/svg/outlined/close.svg"; +import { ReactComponent as ChevronDown } from "@material-design-icons/svg/outlined/keyboard_arrow_down.svg"; +import { IconButton } from "~/Button"; +import { Icon } from "~/Icon"; + +interface MultiAutoCompleteInputIconsProps { + displayResetAction: boolean; + isDisabled?: boolean; + onOpenChange: (open: boolean) => void; + onResetValue: () => void; +} + +export const MultiAutoCompleteInputIcons = (props: MultiAutoCompleteInputIconsProps) => { + return ( +
+ {props.displayResetAction && ( + } label={"Reset"} />} + disabled={props.isDisabled} + onClick={event => { + event.stopPropagation(); + props.onResetValue(); + }} + /> + )} + } + label={"Open list"} + onClick={event => { + event.stopPropagation(); + props.onOpenChange(true); + }} + /> +
+ ); +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteList.tsx b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteList.tsx new file mode 100644 index 00000000000..13a01a39ba4 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/components/MultiAutoCompleteList.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { Command } from "~/Command"; + +interface MultiAutoCompleteListProps extends React.ComponentPropsWithoutRef { + emptyMessage?: React.ReactNode; + isEmpty?: boolean; + isLoading?: boolean; + loadingMessage?: React.ReactNode; + onOptionCreate?: (value: string) => void; + onOptionSelect: (value: string) => void; + optionRenderer?: (item: any, index: number) => React.ReactNode; + options: CommandOptionFormatted[]; + temporaryOption?: CommandOptionFormatted; +} + +export const MultiAutoCompleteList = ({ + emptyMessage, + isEmpty, + isLoading, + loadingMessage, + onOptionCreate, + onOptionSelect, + optionRenderer, + options, + temporaryOption, + ...props +}: MultiAutoCompleteListProps) => { + const renderOptions = React.useCallback( + (items: CommandOptionFormatted[]) => { + if (isEmpty) { + return null; + } + + const elements = []; + + const renderedItems = items.reduce((acc, item, currentIndex) => { + acc.push( + onOptionSelect(item.value)} + onMouseDown={event => event.preventDefault()} + > + {optionRenderer && item.item + ? optionRenderer.call(this, item.item, currentIndex) + : item.label} + + ); + + // Conditionally render the separator if `separator` is true + if (item.separator) { + acc.push(); + } + + return acc; + }, elements); + + if (temporaryOption?.value) { + renderedItems.push( + { + onOptionCreate && onOptionCreate(temporaryOption.value); + }} + onMouseDown={event => event.preventDefault()} + > + {`Add "${temporaryOption.label}" as new option`} + + ); + } + + return renderedItems; + }, + [onOptionSelect, temporaryOption, onOptionCreate, isEmpty, optionRenderer] + ); + + return ( + + {isLoading ? ( + {loadingMessage} + ) : ( + renderOptions(options) + )} + {!isLoading && {emptyMessage}} + + ); +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/components/index.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/components/index.ts new file mode 100644 index 00000000000..dbcd82369d5 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/components/index.ts @@ -0,0 +1,3 @@ +export * from "./MultiAutoCompleteInput"; +export * from "./MultiAutoCompleteInputIcons"; +export * from "./MultiAutoCompleteList"; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/domains/ListCache.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/ListCache.ts new file mode 100644 index 00000000000..34d99cf2639 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/ListCache.ts @@ -0,0 +1,78 @@ +import { makeAutoObservable, runInAction, toJS } from "mobx"; + +export type Constructor = new (...args: any[]) => T; + +export interface IListCachePredicate { + (item: T): boolean; +} + +export interface IListCacheItemUpdater { + (item: T): T; +} + +export interface IListCache { + count(): number; + clear(): void; + hasItems(): boolean; + getItems(predicate?: IListCachePredicate): T[]; + getItem(predicate: IListCachePredicate): T | undefined; + addItems(items: T[]): void; + updateItems(updater: IListCacheItemUpdater): void; + removeItems(predicate: IListCachePredicate): void; +} + +export class ListCache implements IListCache { + private state: T[]; + + constructor() { + this.state = []; + + makeAutoObservable(this); + } + + count() { + return this.state.length; + } + + clear() { + runInAction(() => { + this.state = []; + }); + } + + hasItems() { + return this.state.length > 0; + } + + getItems(predicate?: IListCachePredicate): T[] { + if (predicate) { + return [...this.state.filter(item => predicate(item)).map(item => toJS(item))]; + } + + return [...this.state.map(item => toJS(item))]; + } + + getItem(predicate: IListCachePredicate): T | undefined { + const item = this.state.find(item => predicate(item)); + + return item ? toJS(item) : undefined; + } + + addItems(items: T[]) { + runInAction(() => { + this.state = [...this.state, ...items]; + }); + } + + updateItems(updater: IListCacheItemUpdater) { + runInAction(() => { + this.state = [...this.state.map(item => updater(item))]; + }); + } + + removeItems(predicate: IListCachePredicate) { + runInAction(() => { + this.state = this.state.filter(item => !predicate(item)); + }); + } +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOption.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOption.ts new file mode 100644 index 00000000000..3cf3c829c36 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOption.ts @@ -0,0 +1,3 @@ +import { MultiAutoCompleteOptionDto } from "./MultiAutoCompleteOptionDto"; + +export type MultiAutoCompleteOption = MultiAutoCompleteOptionDto | string; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOptionDto.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOptionDto.ts new file mode 100644 index 00000000000..175eba24bd6 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/MultiAutoCompleteOptionDto.ts @@ -0,0 +1,3 @@ +import { CommandOptionDto } from "~/Command"; + +export type MultiAutoCompleteOptionDto = CommandOptionDto; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/domains/index.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/index.ts new file mode 100644 index 00000000000..2a8a1c6d902 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/domains/index.ts @@ -0,0 +1,3 @@ +export * from "./ListCache"; +export * from "./MultiAutoCompleteOption"; +export * from "./MultiAutoCompleteOptionDto"; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/index.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/index.ts new file mode 100644 index 00000000000..726b0979c3e --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/index.ts @@ -0,0 +1 @@ +export * from "./MultiAutoCompletePrimitive"; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteInputPresenter.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteInputPresenter.ts new file mode 100644 index 00000000000..281a0c632ff --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteInputPresenter.ts @@ -0,0 +1,48 @@ +import { makeAutoObservable } from "mobx"; + +export interface MultiAutoCompleteInputPresenterParams { + placeholder?: string; + displayResetAction?: boolean; +} + +export interface IMultiAutoCompleteInputPresenter { + vm: { + placeholder: string; + value: string; + displayResetAction: boolean; + }; + init: (params: MultiAutoCompleteInputPresenterParams) => void; + setValue: (query: string) => void; + resetValue: () => void; +} + +export class MultiAutoCompleteInputPresenter implements IMultiAutoCompleteInputPresenter { + private searchQuery?: string = undefined; + private placeholder?: string = undefined; + private displayResetAction = true; + + constructor() { + makeAutoObservable(this); + } + + init(params?: MultiAutoCompleteInputPresenterParams) { + this.placeholder = params?.placeholder; + this.displayResetAction = params?.displayResetAction ?? true; + } + + get vm() { + return { + placeholder: this.placeholder || "Start typing or select", + value: this.searchQuery || "", + displayResetAction: this.displayResetAction + }; + } + + public setValue = (value: string) => { + this.searchQuery = value; + }; + + public resetValue = () => { + this.searchQuery = undefined; + }; +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenter.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenter.ts new file mode 100644 index 00000000000..adb2bdca864 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenter.ts @@ -0,0 +1,92 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { CommandOptionFormatter } from "~/Command/domain/CommandOptionFormatter"; +import { ListCache } from "../domains"; + +export interface IMultiAutoCompleteListOptionsPresenterParams { + options?: CommandOption[]; + emptyMessage?: any; + loadingMessage?: any; +} + +export interface IMultiAutoCompleteListOptionsPresenter { + vm: { + options: CommandOptionFormatted[]; + emptyMessage: string; + loadingMessage: string; + isOpen: boolean; + isEmpty: boolean; + }; + init: (params: IMultiAutoCompleteListOptionsPresenterParams) => void; + setListOpenState: (open: boolean) => void; + addOption: (option: CommandOption) => void; + setSelectedOption: (value: string) => void; + removeSelectedOption: (value: string) => void; + resetSelectedOptions: () => void; +} + +export class MultiAutoCompleteListOptionsPresenter + implements IMultiAutoCompleteListOptionsPresenter +{ + private isOpen = false; + private emptyMessage = "No results."; + private loadingMessage = "Loading..."; + private options = new ListCache(); + + constructor() { + makeAutoObservable(this); + } + + init(params: IMultiAutoCompleteListOptionsPresenterParams) { + this.options.clear(); + params.options && this.options.addItems(params.options); + this.emptyMessage = params.emptyMessage || this.emptyMessage; + this.loadingMessage = params.loadingMessage || this.loadingMessage; + } + + get vm() { + return { + options: this.options.getItems().map(option => CommandOptionFormatter.format(option)), + emptyMessage: this.emptyMessage, + loadingMessage: this.loadingMessage, + isOpen: this.isOpen, + isEmpty: !this.options.hasItems() + }; + } + + setListOpenState = (open: boolean) => { + this.isOpen = open; + }; + + setSelectedOption = (value: string) => { + this.options.updateItems(option => { + if (option.value === value) { + option.selected = true; + } + + return option; + }); + }; + + removeSelectedOption = (value: string) => { + this.options.updateItems(option => { + if (option.value === value) { + option.selected = false; + } + + return option; + }); + }; + + resetSelectedOptions = () => { + this.options.updateItems(option => { + option.selected = false; + return option; + }); + }; + + addOption = (option: CommandOption) => { + this.options.addItems([option]); + }; +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenterWithUniqueValues.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenterWithUniqueValues.ts new file mode 100644 index 00000000000..1cd2cf241bc --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteListOptionsPresenterWithUniqueValues.ts @@ -0,0 +1,47 @@ +import { CommandOption } from "~/Command/domain/CommandOption"; +import { + IMultiAutoCompleteListOptionsPresenter, + IMultiAutoCompleteListOptionsPresenterParams +} from "./MultiAutoCompleteListOptionsPresenter"; + +export class MultiAutoCompleteListOptionsPresenterWithUniqueValues + implements IMultiAutoCompleteListOptionsPresenter +{ + private decoretee: IMultiAutoCompleteListOptionsPresenter; + + constructor(decoretee: IMultiAutoCompleteListOptionsPresenter) { + this.decoretee = decoretee; + } + + init(params: IMultiAutoCompleteListOptionsPresenterParams) { + const options = params.options?.filter(option => !option.selected); + this.decoretee.init({ + ...params, + options + }); + } + + get vm() { + return this.decoretee.vm; + } + + setListOpenState = (open: boolean) => { + this.decoretee.setListOpenState(open); + }; + + setSelectedOption = (value: string) => { + this.decoretee.setSelectedOption(value); + }; + + removeSelectedOption = (value: string) => { + this.decoretee.removeSelectedOption(value); + }; + + resetSelectedOptions = () => { + this.decoretee.resetSelectedOptions(); + }; + + addOption = (option: CommandOption) => { + this.decoretee.addOption(option); + }; +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.test.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.test.ts new file mode 100644 index 00000000000..d808e5f13c2 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.test.ts @@ -0,0 +1,652 @@ +import { + IMultiAutoCompletePresenter, + MultiAutoCompletePresenter +} from "./MultiAutoCompletePresenter"; +import { MultiAutoCompleteInputPresenter } from "./MultiAutoCompleteInputPresenter"; +import { MultiAutoCompleteSelectedOptionPresenter } from "./MultiAutoCompleteSelectedOptionsPresenter"; +import { MultiAutoCompleteListOptionsPresenter } from "./MultiAutoCompleteListOptionsPresenter"; + +describe("MultiAutoCompletePresenter", () => { + let presenter: IMultiAutoCompletePresenter; + const onValuesChange = jest.fn(); + const onOpenChange = jest.fn(); + const onValuesReset = jest.fn(); + + beforeEach(() => { + const inputPresenter = new MultiAutoCompleteInputPresenter(); + const selectedOptionsPresenter = new MultiAutoCompleteSelectedOptionPresenter(); + const optionsListPresenter = new MultiAutoCompleteListOptionsPresenter(); + + presenter = new MultiAutoCompletePresenter( + inputPresenter, + selectedOptionsPresenter, + optionsListPresenter + ); + }); + + it("should return the compatible `vm.inputVm` based on params", () => { + // `placeholder` + { + presenter.init({ placeholder: "Custom placeholder", onValuesChange }); + expect(presenter.vm.inputVm.placeholder).toEqual("Custom placeholder"); + } + + { + // default: no params + presenter.init({ onValuesChange }); + expect(presenter.vm.inputVm.placeholder).toEqual("Start typing or select"); + expect(presenter.vm.inputVm.displayResetAction).toEqual(true); + } + }); + + it("should return the compatible `vm.selectedOptionsVm` based on params`", () => { + // `values` + { + presenter.init({ + options: ["Option 1", "Option 2", "Option 3"], + values: ["Option 1", "Option 2"], + onValuesChange + }); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + value: "Option 2", + label: "Option 2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + } + + // default: no params + { + presenter.init({ options: ["Option 1", "Option 2"], onValuesChange }); + expect(presenter.vm.selectedOptionsVm.options).toEqual([]); + } + }); + + it("should return the compatible `vm.optionsListVm` based on params", () => { + // with `options` as string + { + presenter.init({ options: ["Option 1", "Option 2"], onValuesChange }); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + value: "Option 2", + label: "Option 2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + // with `options` as formatted options + { + presenter.init({ + onValuesChange, + options: [ + { + value: "option-1", + label: "Option 1" + }, + { + value: "option-2", + label: "Option 2" + }, + { + value: "option-3", + label: "Option 3", + disabled: true + }, + { + value: "option-4", + label: "Option 4", + separator: true + }, + { + value: "option-5", + label: "Option 5", + item: { + anyKey1: "custom-value", + anyKey2: 2 + } + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "option-1", + label: "Option 1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + value: "option-2", + label: "Option 2", + disabled: false, + selected: false, // `selected` is overwritten by the presenter + separator: false, + item: null + }, + { + value: "option-3", + label: "Option 3", + disabled: true, + selected: false, + separator: false, + item: null + }, + { + value: "option-4", + label: "Option 4", + disabled: false, + selected: false, + separator: true, + item: null + }, + { + value: "option-5", + label: "Option 5", + disabled: false, + selected: false, + separator: false, + item: { + anyKey1: "custom-value", + anyKey2: 2 + } + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + // with `options` and `value` + { + presenter.init({ + onValuesChange, + options: ["Option 1", "Option 2"], + values: ["Option 1"] + }); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + value: "Option 2", + label: "Option 2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(false); + } + + { + // default: no params + presenter.init({ onValuesChange }); + expect(presenter.vm.optionsListVm.options).toEqual([]); + expect(presenter.vm.optionsListVm.emptyMessage).toEqual("No results."); + expect(presenter.vm.optionsListVm.loadingMessage).toEqual("Loading..."); + expect(presenter.vm.optionsListVm.isOpen).toEqual(false); + expect(presenter.vm.optionsListVm.isEmpty).toEqual(true); + } + }); + + it("should change the `optionsListVm` and `selectedOptionsVm` when `setSelectedOption` is called", () => { + presenter.init({ + onValuesChange, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + + presenter.setSelectedOption("option-2"); + expect(onValuesChange).toHaveBeenCalledWith(["option-2"]); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + presenter.setSelectedOption("option-1"); + expect(onValuesChange).toHaveBeenCalledWith(["option-2", "option-1"]); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + }); + + it("should change the `optionsListVm` and `selectedOptionsVm` when `removeSelectedOption` is called", () => { + presenter.init({ + onValuesChange, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ], + values: ["option-2", "option-1"] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + presenter.removeSelectedOption("option-2"); + expect(onValuesChange).toHaveBeenCalledWith(["option-2"]); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + presenter.removeSelectedOption("option-1"); + expect(onValuesChange).toHaveBeenCalledWith([]); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([]); + }); + + it("should set the internal `inputValue` when `setInputValue` is called", () => { + presenter.init({ onValuesChange }); + + presenter.searchOption("value"); + expect(presenter.vm.inputVm.value).toEqual("value"); + }); + + it("should set the option as `selected` when the presenter is initialized with a value", () => { + presenter.init({ + onValuesChange, + values: ["option-1"], + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + }); + + it("should change the `optionListVm` and `selectedOptionsVm` when `resetSelectedOptions` is called", () => { + presenter.init({ + onValuesChange, + onValuesReset, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ] + }); + + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + + presenter.setSelectedOption("option-1"); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + + presenter.resetSelectedOptions(); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: false, + separator: false, + item: null + } + ]); + expect(presenter.vm.inputVm.value).toEqual(""); + expect(presenter.vm.selectedOptionsVm.options).toEqual([]); + + expect(onValuesChange).toHaveBeenCalledWith([]); + expect(onValuesReset).toHaveBeenCalled(); + }); + + it("should change `listVm` and call `onOpenChange` when `setListOpenState` is called", () => { + // let's open it + presenter.init({ onValuesChange, onOpenChange }); + presenter.setListOpenState(true); + expect(presenter.vm.optionsListVm.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true); + + // let's close it + presenter.setListOpenState(false); + expect(presenter.vm.optionsListVm.isOpen).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("should be able to create new options if `allowFreeInput` is set to true", () => { + // This feature is disabled by default + presenter.init({ onValuesChange }); + presenter.searchOption("New Option 1"); + presenter.createOption("New Option 1"); + expect(presenter.vm.selectedOptionsVm.isEmpty).toEqual(true); + expect(presenter.vm.selectedOptionsVm.options).toEqual([]); + }); + + it("should not display the reset action if `displayResetAction` is set to `false` and option is selected", () => { + presenter.init({ + onValuesChange, + options: [ + { + label: "Option 1", + value: "option-1" + }, + { + label: "Option 2", + value: "option-2" + } + ], + displayResetAction: false + }); + + presenter.setSelectedOption("option-2"); + expect(onValuesChange).toHaveBeenCalledWith(["option-2"]); + // `displayResetAction` is set to `false` and option is selected + expect(presenter.vm.inputVm.displayResetAction).toEqual(false); + expect(presenter.vm.optionsListVm.options).toEqual([ + { + label: "Option 1", + value: "option-1", + disabled: false, + selected: false, + separator: false, + item: null + }, + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "Option 2", + value: "option-2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + }); +}); diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.ts new file mode 100644 index 00000000000..792138ed133 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenter.ts @@ -0,0 +1,170 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { MultiAutoCompleteOption } from "../domains"; +import { IMultiAutoCompleteInputPresenter } from "./MultiAutoCompleteInputPresenter"; +import { IMultiAutoCompleteSelectedOptionsPresenter } from "./MultiAutoCompleteSelectedOptionsPresenter"; +import { IMultiAutoCompleteListOptionsPresenter } from "./MultiAutoCompleteListOptionsPresenter"; +import { IMultiAutoCompleteTemporaryOptionPresenter } from "./MultiAutoCompleteTemporaryOptionPresenter"; + +interface MultiAutoCompletePresenterParams { + allowFreeInput?: boolean; + displayResetAction?: boolean; + emptyMessage?: any; + loadingMessage?: any; + options?: MultiAutoCompleteOption[]; + placeholder?: string; + values?: string[]; + onValuesReset?: () => void; + onValuesChange: (values: string[]) => void; + onOpenChange?: (open: boolean) => void; +} + +interface IMultiAutoCompletePresenter { + vm: { + inputVm: IMultiAutoCompleteInputPresenter["vm"]; + selectedOptionsVm: IMultiAutoCompleteSelectedOptionsPresenter["vm"]; + optionsListVm: IMultiAutoCompleteListOptionsPresenter["vm"]; + temporaryOptionVm: IMultiAutoCompleteTemporaryOptionPresenter["vm"]; + }; + init: (params: MultiAutoCompletePresenterParams) => void; + setListOpenState: (open: boolean) => void; + searchOption: (value: string) => void; + setSelectedOption: (value: string) => void; + removeSelectedOption: (value: string) => void; + resetSelectedOptions: () => void; + createOption: (value: string) => void; +} + +class MultiAutoCompletePresenter implements IMultiAutoCompletePresenter { + private params?: MultiAutoCompletePresenterParams = undefined; + private inputPresenter: IMultiAutoCompleteInputPresenter; + private selectedOptionsPresenter: IMultiAutoCompleteSelectedOptionsPresenter; + private optionsListPresenter: IMultiAutoCompleteListOptionsPresenter; + + constructor( + inputPresenter: IMultiAutoCompleteInputPresenter, + selectedOptionsPresenter: IMultiAutoCompleteSelectedOptionsPresenter, + optionsListPresenter: IMultiAutoCompleteListOptionsPresenter + ) { + this.inputPresenter = inputPresenter; + this.selectedOptionsPresenter = selectedOptionsPresenter; + this.optionsListPresenter = optionsListPresenter; + makeAutoObservable(this); + } + + init(params: MultiAutoCompletePresenterParams) { + this.params = params; + + this.inputPresenter.init({ + placeholder: params.placeholder, + displayResetAction: params.displayResetAction + }); + + const listOptions = this.getListOptions(params.options, params.values); + this.optionsListPresenter.init({ + options: listOptions, + emptyMessage: params.emptyMessage, + loadingMessage: params.loadingMessage + }); + this.selectedOptionsPresenter.init({ + options: this.getSelectedOptions(listOptions, params.values) + }); + } + + get vm() { + return { + inputVm: this.inputPresenter.vm, + selectedOptionsVm: this.selectedOptionsPresenter.vm, + optionsListVm: this.optionsListPresenter.vm, + temporaryOptionVm: { + option: undefined + } + }; + } + + setListOpenState = (open: boolean) => { + this.optionsListPresenter.setListOpenState(open); + this.params?.onOpenChange?.(open); + }; + + public searchOption = (value: string) => { + this.inputPresenter.setValue(value); + }; + + public setSelectedOption = (value: string) => { + this.inputPresenter.resetValue(); + this.optionsListPresenter.setSelectedOption(value); + + const option = this.vm.optionsListVm.options.find(option => option.value === value); + + const commandOption = option + ? CommandOption.create(option) + : CommandOption.createFromString(value); + + commandOption.selected = true; + this.selectedOptionsPresenter.addOption(commandOption); + + this.params?.onValuesChange(this.getSelectedValues()); + }; + + public removeSelectedOption = (value: string) => { + this.optionsListPresenter.removeSelectedOption(value); + this.selectedOptionsPresenter.removeOption(value); + + this.params?.onValuesChange(this.getSelectedValues()); + }; + + public resetSelectedOptions = () => { + this.optionsListPresenter.resetSelectedOptions(); + this.selectedOptionsPresenter.resetOptions(); + this.inputPresenter.resetValue(); + + this.params?.onValuesChange(this.getSelectedValues()); + this.params?.onValuesReset?.(); + }; + + public createOption = () => { + return; + }; + + private getListOptions( + options: MultiAutoCompleteOption[] = [], + values: string[] = [] + ): CommandOption[] { + return options.map(option => { + const commandOption = + typeof option === "string" + ? CommandOption.createFromString(option) + : CommandOption.create(option); + + commandOption.selected = values.includes(commandOption.value); + + return commandOption; + }); + } + + private getSelectedOptions( + commandOptions: CommandOption[], + values: string[] = [] + ): CommandOption[] { + return values.map(value => { + const option = commandOptions.find(option => option.value === value); + + if (!option) { + return CommandOption.createFromString(value); + } + + return option; + }); + } + + private getSelectedValues() { + return this.selectedOptionsPresenter.vm.options.map(option => option.value); + } +} + +export { + MultiAutoCompletePresenter, + type IMultiAutoCompletePresenter, + type MultiAutoCompletePresenterParams +}; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.test.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.test.ts new file mode 100644 index 00000000000..4b60226a8b4 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.test.ts @@ -0,0 +1,83 @@ +import { + IMultiAutoCompletePresenter, + MultiAutoCompletePresenter +} from "./MultiAutoCompletePresenter"; +import { MultiAutoCompleteInputPresenter } from "./MultiAutoCompleteInputPresenter"; +import { MultiAutoCompleteSelectedOptionPresenter } from "./MultiAutoCompleteSelectedOptionsPresenter"; +import { MultiAutoCompleteListOptionsPresenter } from "./MultiAutoCompleteListOptionsPresenter"; +import { MultiAutoCompleteTemporaryOptionPresenter } from "~/MultiAutoComplete/primitives/presenters/MultiAutoCompleteTemporaryOptionPresenter"; +import { MultiAutoCompletePresenterWithFreeInput } from "~/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput"; + +describe("MultiAutoCompletePresenterWithFreeInput", () => { + let presenter: IMultiAutoCompletePresenter; + const onValuesChange = jest.fn(); + + beforeEach(() => { + const inputPresenter = new MultiAutoCompleteInputPresenter(); + const selectedOptionsPresenter = new MultiAutoCompleteSelectedOptionPresenter(); + const optionsListPresenter = new MultiAutoCompleteListOptionsPresenter(); + const temporaryOptionPresenter = new MultiAutoCompleteTemporaryOptionPresenter(); + + const decoretee = new MultiAutoCompletePresenter( + inputPresenter, + selectedOptionsPresenter, + optionsListPresenter + ); + presenter = new MultiAutoCompletePresenterWithFreeInput( + temporaryOptionPresenter, + decoretee + ); + }); + + it("should return the compatible `vm.temporaryOptionVm` based on params", () => { + // default: no params + presenter.init({ onValuesChange }); + expect(presenter.vm.temporaryOptionVm.option).toEqual(undefined); + }); + + it("should be able to create new options", () => { + presenter.init({ onValuesChange }); + + // Let's create the first option + presenter.searchOption("New Option 1"); + presenter.createOption("New Option 1"); + expect(presenter.vm.selectedOptionsVm.isEmpty).toEqual(false); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "New Option 1", + value: "New Option 1", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + expect(onValuesChange).toHaveBeenCalledWith(["New Option 1"]); + expect(presenter.vm.inputVm.value).toEqual(""); + + // Let's create the second option + presenter.searchOption("New Option 2"); + presenter.createOption("New Option 2"); + expect(presenter.vm.selectedOptionsVm.isEmpty).toEqual(false); + expect(presenter.vm.selectedOptionsVm.options).toEqual([ + { + label: "New Option 1", + value: "New Option 1", + disabled: false, + selected: true, + separator: false, + item: null + }, + { + label: "New Option 2", + value: "New Option 2", + disabled: false, + selected: true, + separator: false, + item: null + } + ]); + expect(onValuesChange).toHaveBeenCalledWith(["New Option 1", "New Option 2"]); + expect(presenter.vm.inputVm.value).toEqual(""); + }); +}); diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.ts new file mode 100644 index 00000000000..6875bf93859 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompletePresenterWithFreeInput.ts @@ -0,0 +1,64 @@ +import { makeAutoObservable } from "mobx"; +import { IMultiAutoCompleteTemporaryOptionPresenter } from "./MultiAutoCompleteTemporaryOptionPresenter"; +import { + IMultiAutoCompletePresenter, + MultiAutoCompletePresenterParams +} from "./MultiAutoCompletePresenter"; + +class MultiAutoCompletePresenterWithFreeInput implements IMultiAutoCompletePresenter { + private temporaryOptionPresenter: IMultiAutoCompleteTemporaryOptionPresenter; + private multiAutoCompletePresenter: IMultiAutoCompletePresenter; + + constructor( + temporaryOptionPresenter: IMultiAutoCompleteTemporaryOptionPresenter, + multiAutoCompletePresenter: IMultiAutoCompletePresenter + ) { + this.temporaryOptionPresenter = temporaryOptionPresenter; + this.multiAutoCompletePresenter = multiAutoCompletePresenter; + makeAutoObservable(this); + } + + init(params: MultiAutoCompletePresenterParams) { + this.multiAutoCompletePresenter.init(params); + this.temporaryOptionPresenter.init(); + } + + get vm() { + return { + ...this.multiAutoCompletePresenter.vm, + temporaryOptionVm: this.temporaryOptionPresenter.vm + }; + } + + setListOpenState = (open: boolean) => { + this.multiAutoCompletePresenter.setListOpenState(open); + }; + + public searchOption = (value: string) => { + this.multiAutoCompletePresenter.searchOption(value); + this.temporaryOptionPresenter.setOption(value); + }; + + public setSelectedOption = (value: string) => { + this.multiAutoCompletePresenter.setSelectedOption(value); + }; + + public removeSelectedOption = (value: string) => { + this.multiAutoCompletePresenter.removeSelectedOption(value); + this.temporaryOptionPresenter.resetOption(); + }; + + public resetSelectedOptions = () => { + this.multiAutoCompletePresenter.resetSelectedOptions(); + this.temporaryOptionPresenter.resetOption(); + }; + + public createOption = (value: string) => { + this.multiAutoCompletePresenter.createOption(value); + this.multiAutoCompletePresenter.searchOption(""); + this.multiAutoCompletePresenter.setSelectedOption(value); + this.temporaryOptionPresenter.resetOption(); + }; +} + +export { MultiAutoCompletePresenterWithFreeInput }; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteSelectedOptionsPresenter.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteSelectedOptionsPresenter.ts new file mode 100644 index 00000000000..d74a3f7fecd --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteSelectedOptionsPresenter.ts @@ -0,0 +1,54 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { CommandOptionFormatter } from "~/Command/domain/CommandOptionFormatter"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { ListCache } from "../domains"; + +interface IMultiAutoCompleteSelectedOptionsParams { + options?: CommandOption[]; +} + +export interface IMultiAutoCompleteSelectedOptionsPresenter { + vm: { + options: CommandOptionFormatted[]; + isEmpty: boolean; + }; + init: (params: IMultiAutoCompleteSelectedOptionsParams) => void; + addOption: (option: CommandOption) => void; + removeOption: (value: string) => void; + resetOptions: () => void; +} + +export class MultiAutoCompleteSelectedOptionPresenter + implements IMultiAutoCompleteSelectedOptionsPresenter +{ + private options = new ListCache(); + + constructor() { + makeAutoObservable(this); + } + + init(params: IMultiAutoCompleteSelectedOptionsParams) { + this.options.clear(); + params.options && this.options.addItems(params.options); + } + + get vm() { + return { + options: this.options.getItems().map(option => CommandOptionFormatter.format(option)), + isEmpty: !this.options.hasItems() + }; + } + + public addOption = (option: CommandOption) => { + this.options.addItems([option]); + }; + + public removeOption = (value: string) => { + this.options.removeItems(option => option.value === value); + }; + + public resetOptions = () => { + this.options.clear(); + }; +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteTemporaryOptionPresenter.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteTemporaryOptionPresenter.ts new file mode 100644 index 00000000000..d5f661cfb1e --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/MultiAutoCompleteTemporaryOptionPresenter.ts @@ -0,0 +1,41 @@ +import { makeAutoObservable } from "mobx"; +import { CommandOption } from "~/Command/domain/CommandOption"; +import { CommandOptionFormatted } from "~/Command/domain/CommandOptionFormatted"; +import { CommandOptionFormatter } from "~/Command/domain/CommandOptionFormatter"; + +export interface IMultiAutoCompleteTemporaryOptionPresenter { + vm: { + option?: CommandOptionFormatted; + }; + init: () => void; + setOption: (value: string) => void; + resetOption: () => void; +} + +export class MultiAutoCompleteTemporaryOptionPresenter + implements IMultiAutoCompleteTemporaryOptionPresenter +{ + private option?: CommandOption = undefined; + + constructor() { + makeAutoObservable(this); + } + + init() { + return; + } + + get vm() { + return { + option: this.option ? CommandOptionFormatter.format(this.option) : undefined + }; + } + + public setOption = (value: string) => { + this.option = CommandOption.createFromString(value); + }; + + public resetOption = () => { + this.option = undefined; + }; +} diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/index.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/index.ts new file mode 100644 index 00000000000..4ecd12bda21 --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/presenters/index.ts @@ -0,0 +1,7 @@ +export * from "./MultiAutoCompletePresenter"; +export * from "./MultiAutoCompletePresenterWithFreeInput"; +export * from "./MultiAutoCompleteInputPresenter"; +export * from "./MultiAutoCompleteListOptionsPresenter"; +export * from "./MultiAutoCompleteListOptionsPresenterWithUniqueValues"; +export * from "./MultiAutoCompleteSelectedOptionsPresenter"; +export * from "./MultiAutoCompleteTemporaryOptionPresenter"; diff --git a/packages/admin-ui/src/MultiAutoComplete/primitives/useMultiAutoComplete.ts b/packages/admin-ui/src/MultiAutoComplete/primitives/useMultiAutoComplete.ts new file mode 100644 index 00000000000..cdf1956c8fb --- /dev/null +++ b/packages/admin-ui/src/MultiAutoComplete/primitives/useMultiAutoComplete.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from "react"; +import { autorun } from "mobx"; +import { MultiAutoCompletePrimitiveProps } from "./MultiAutoCompletePrimitive"; +import { + MultiAutoCompleteInputPresenter, + MultiAutoCompleteListOptionsPresenter, + MultiAutoCompleteListOptionsPresenterWithUniqueValues, + MultiAutoCompletePresenter, + MultiAutoCompletePresenterParams, + MultiAutoCompletePresenterWithFreeInput, + MultiAutoCompleteSelectedOptionPresenter +} from "./presenters"; +import { MultiAutoCompleteTemporaryOptionPresenter } from "~/MultiAutoComplete/primitives/presenters/MultiAutoCompleteTemporaryOptionPresenter"; + +export const useMultiAutoComplete = (props: MultiAutoCompletePrimitiveProps) => { + const params: MultiAutoCompletePresenterParams = useMemo( + () => ({ + emptyMessage: props.emptyMessage, + loadingMessage: props.loadingMessage, + options: props.options, + placeholder: props.placeholder, + values: props.values, + onValuesChange: props.onValuesChange, + onValuesReset: props.onValuesReset, + onOpenChange: props.onOpenChange, + displayResetAction: props.displayResetAction + }), + [ + props.emptyMessage, + props.loadingMessage, + props.options, + props.placeholder, + props.values, + props.onValuesChange, + props.onValuesReset, + props.onOpenChange, + props.displayResetAction + ] + ); + + const presenter = useMemo(() => { + const optionsListPresenter = new MultiAutoCompleteListOptionsPresenter(); + const optionsListWithUniqueValues = + new MultiAutoCompleteListOptionsPresenterWithUniqueValues(optionsListPresenter); + + const inputPresenter = new MultiAutoCompleteInputPresenter(); + const selectedOptionsPresenter = new MultiAutoCompleteSelectedOptionPresenter(); + + let presenter = new MultiAutoCompletePresenter( + inputPresenter, + selectedOptionsPresenter, + optionsListPresenter + ); + + if (props?.uniqueValues) { + presenter = new MultiAutoCompletePresenter( + inputPresenter, + selectedOptionsPresenter, + optionsListWithUniqueValues + ); + } + + if (props?.allowFreeInput) { + const temporaryOptionsPresenter = new MultiAutoCompleteTemporaryOptionPresenter(); + const withPresenter = new MultiAutoCompletePresenterWithFreeInput( + temporaryOptionsPresenter, + presenter + ); + withPresenter.init(params); + return withPresenter; + } else { + presenter.init(params); + return presenter; + } + }, [props.allowFreeInput, props.uniqueValues]); + + const [vm, setVm] = useState(presenter.vm); + + useEffect(() => { + presenter.init(params); + }, [params, presenter]); + + useEffect(() => { + return autorun(() => { + setVm(presenter.vm); + }); + }, [presenter]); + + return { + vm, + setSelectedOption: presenter.setSelectedOption, + removeSelectedOption: presenter.removeSelectedOption, + searchOption: presenter.searchOption, + resetSelectedOptions: presenter.resetSelectedOptions, + setListOpenState: presenter.setListOpenState, + createOption: presenter.createOption + }; +}; diff --git a/packages/admin-ui/src/Popover/Popover.tsx b/packages/admin-ui/src/Popover/Popover.tsx new file mode 100644 index 00000000000..4574cc5ef66 --- /dev/null +++ b/packages/admin-ui/src/Popover/Popover.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { cn, withStaticProps } from "~/utils"; + +type PopoverContentProps = PopoverPrimitive.PopoverContentProps; + +const PopoverContent = ({ + className, + align = "center", + sideOffset = 6, + ...props +}: PopoverContentProps) => ( + + + +); + +const Popover = withStaticProps(PopoverPrimitive.Root, { + Anchor: PopoverPrimitive.Anchor, + Content: PopoverContent, + Portal: PopoverPrimitive.Portal, + Trigger: PopoverPrimitive.Trigger +}); + +export { Popover, type PopoverContent }; diff --git a/packages/admin-ui/src/Popover/index.ts b/packages/admin-ui/src/Popover/index.ts new file mode 100644 index 00000000000..72f124f6d38 --- /dev/null +++ b/packages/admin-ui/src/Popover/index.ts @@ -0,0 +1 @@ +export * from "./Popover"; diff --git a/packages/admin-ui/src/Select/Select.tsx b/packages/admin-ui/src/Select/Select.tsx index bc8c48b7783..af72475c1a7 100644 --- a/packages/admin-ui/src/Select/Select.tsx +++ b/packages/admin-ui/src/Select/Select.tsx @@ -20,20 +20,18 @@ const DecoratableSelect = ({ validation, ...props }: SelectGroupProps) => { - { - const { isValid: validationIsValid, message: validationMessage } = validation || {}; - const invalid = useMemo(() => validationIsValid === false, [validationIsValid]); + const { isValid: validationIsValid, message: validationMessage } = validation || {}; + const invalid = useMemo(() => validationIsValid === false, [validationIsValid]); - return ( -
- - - - - -
- ); - } + return ( +
+ + + + + +
+ ); }; const Select = makeDecoratable("Select", DecoratableSelect); diff --git a/packages/admin-ui/src/Separator/Separator.tsx b/packages/admin-ui/src/Separator/Separator.tsx index fc6e654f0fb..eef1a21ff89 100644 --- a/packages/admin-ui/src/Separator/Separator.tsx +++ b/packages/admin-ui/src/Separator/Separator.tsx @@ -43,24 +43,25 @@ const separatorVariants = cva("shrink-0", { } }); -export type SeparatorProps = React.ComponentPropsWithoutRef & +type SeparatorProps = React.ComponentPropsWithoutRef & VariantProps; -const SeparatorBase = React.forwardRef< - React.ElementRef, - SeparatorProps ->(({ className, orientation, margin, variant, decorative = true, ...props }, ref) => ( +const SeparatorBase = ({ + className, + orientation, + margin, + variant, + decorative = true, + ...props +}: SeparatorProps) => ( -)); - -SeparatorBase.displayName = SeparatorPrimitive.Root.displayName; +); const Separator = makeDecoratable("Separator", SeparatorBase); -export { Separator }; +export { Separator, type SeparatorProps }; diff --git a/packages/admin-ui/src/Textarea/TextareaPrimitive.tsx b/packages/admin-ui/src/Textarea/TextareaPrimitive.tsx index 4ca593a481b..22d779f4791 100644 --- a/packages/admin-ui/src/Textarea/TextareaPrimitive.tsx +++ b/packages/admin-ui/src/Textarea/TextareaPrimitive.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { makeDecoratable, cva, type VariantProps, cn } from "~/utils"; +import { cn, cva, type VariantProps } from "~/utils"; const textareaVariants = cva( [ @@ -61,21 +61,27 @@ const textareaVariants = cva( } ); -type TextareaPrimitiveProps = React.ComponentProps<"textarea"> & - VariantProps; +interface TextareaPrimitiveProps + extends React.ComponentProps<"textarea">, + VariantProps { + textareaRef?: React.Ref; +} -const DecoratableTextareaPrimitive = React.forwardRef( - ({ className, variant, invalid, size, ...props }, ref) => { - return ( -