Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions fields/core/select/edit-component.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo } from "react";
import { useMemo, useState } from "react";
import { PlusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Combobox,
Expand Down Expand Up @@ -37,18 +38,56 @@ const EditComponent = (props: any) => {
const { value, field, onChange } = props;
const isReadonly = Boolean(field?.readonly);
const multiple = Boolean(field.options?.multiple);
const creatable = Boolean(field.options?.creatable);
const storeAsObject =
field?.type === "reference" && field.options?.store === "object";
const anchor = useComboboxAnchor();
const [inputValue, setInputValue] = useState("");

const options = useMemo(
// The static options defined in the field schema
const baseOptions = useMemo(
() =>
Array.isArray(field.options?.values)
? field.options.values.map(normalizeOption)
: [],
[field.options?.values],
);

// When creatable, also include any already-selected free-form values that
// aren't in the static list, so they display correctly when the form loads.
const options = useMemo(() => {
if (!creatable) return baseOptions;

const existingValues = new Set(baseOptions.map((o: Option) => o.value));
const selectedItems = multiple
? (Array.isArray(value) ? value : [])
: (value != null && value !== "" ? [value] : []);

const extra: Option[] = selectedItems
.map((v: any) =>
typeof v === "object" && v !== null ? normalizeOption(v) : { value: String(v), label: String(v) }
)
.filter((o: Option) => o.value !== "" && !existingValues.has(o.value));

return extra.length > 0 ? [...baseOptions, ...extra] : baseOptions;
}, [baseOptions, creatable, value, multiple]);

// Synthetic "Create …" option shown when the typed value doesn't match anything
const createOption = useMemo<Option | null>(() => {
if (!creatable || !inputValue.trim()) return null;
const trimmed = inputValue.trim();
const alreadyExists = options.some(
(o: Option) => o.value.toLowerCase() === trimmed.toLowerCase(),
);
return alreadyExists ? null : { value: trimmed, label: `Create "${trimmed}"` };
}, [creatable, inputValue, options]);

// Full list passed to the Combobox
const allOptions = useMemo(
() => (createOption ? [...options, createOption] : options),
[options, createOption],
);

const selectedValue = useMemo(() => {
if (multiple) {
const values = Array.isArray(value) ? value : [];
Expand Down Expand Up @@ -84,6 +123,7 @@ const EditComponent = (props: any) => {

const handleValueChange = (nextValue: Option[] | Option | null) => {
if (isReadonly) return;
setInputValue("");
const toOutput = (option: Option) =>
storeAsObject ? option : option.value;

Expand All @@ -95,15 +135,27 @@ const EditComponent = (props: any) => {
onChange(nextValue ? toOutput(nextValue as Option) : null);
};

const filterFn = (option: Option, inputVal: string) => {
// Always show the create option, let others match by label
if (creatable && createOption && option.value === createOption.value) {
// show "Create..." if it should be shown
return true;
}
// Standard case-insensitive substring match for filtering
return option.label.toLowerCase().includes(inputVal.trim().toLowerCase());
};

return (
<Combobox
items={options}
items={allOptions}
multiple={multiple}
value={selectedValue as any}
onValueChange={handleValueChange as any}
onInputValueChange={creatable && !isReadonly ? setInputValue : undefined}
readOnly={isReadonly}
isItemEqualToValue={(item, selected) => item.value === selected?.value}
autoHighlight
filter={filterFn}
>
{multiple ? (
<>
Expand Down Expand Up @@ -135,7 +187,14 @@ const EditComponent = (props: any) => {
<ComboboxList>
{(option: Option) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
{creatable && option.value === createOption?.value ? (
<>
<PlusIcon className="mr-1 size-3.5 shrink-0" />
{option.label}
</>
) : (
option.label
)}
</ComboboxItem>
)}
</ComboboxList>
Expand All @@ -154,7 +213,14 @@ const EditComponent = (props: any) => {
<ComboboxList>
{(option: Option) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
{creatable && option.value === createOption?.value ? (
<>
<PlusIcon className="mr-1 size-3.5 shrink-0" />
{option.label}
</>
) : (
option.label
)}
</ComboboxItem>
)}
</ComboboxList>
Expand Down
11 changes: 7 additions & 4 deletions fields/core/select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Field } from "@/types/field";
import { EditComponent } from "./edit-component";

const schema = (field: Field) => {
const creatable = Boolean(field.options?.creatable);
const normalizedValues = Array.isArray(field.options?.values)
? field.options.values.map((item) => (
typeof item === "object"
Expand All @@ -13,10 +14,12 @@ const schema = (field: Field) => {
const min = typeof field.options?.min === "number" ? field.options.min : undefined;
const max = typeof field.options?.max === "number" ? field.options.max : undefined;

const optionSchema = z.string().refine(
(value) => normalizedValues.includes(value),
{ message: normalizedValues.length === 0 ? "This select field requires options.values" : "Invalid option" }
);
const optionSchema = creatable
? z.string()
: z.string().refine(
(value) => normalizedValues.includes(value),
{ message: normalizedValues.length === 0 ? "This select field requires options.values" : "Invalid option" }
);

if (field.options?.multiple) {
let zodSchema = z.array(optionSchema);
Expand Down