ui/machineTags: fix keyboard and select logic

This commit is contained in:
Johannes Kirschbauer
2025-09-01 18:15:48 +02:00
parent 7c1c8a5486
commit 3a7d7afaab
7 changed files with 204 additions and 81 deletions

View File

@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { MachineTags, MachineTagsProps } from "./MachineTags";
import { createForm, setValue } from "@modular-forms/solid";
import { Button } from "../Button/Button";
const meta = {
title: "Components/MachineTags",
component: MachineTags,
} satisfies Meta<MachineTagsProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [formStore, { Field, Form }] = createForm<{ tags: string[] }>({
initialValues: { tags: ["nixos"] },
});
const handleSubmit = (values: { tags: string[] }) => {
console.log("submitting", values);
};
const readonly = ["nixos"];
const options = ["foo"];
return (
<Form onSubmit={handleSubmit}>
<Field name="tags" type="string[]">
{(field, props) => (
<MachineTags
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
name="Tags"
defaultOptions={options}
readonlyOptions={readonly}
readOnly={false}
defaultValue={field.value}
/>
)}
</Field>
<Button type="submit" hierarchy="primary">
Submit
</Button>
</Form>
);
},
};

View File

@@ -1,6 +1,13 @@
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js"; import {
createEffect,
on,
createSignal,
For,
Show,
splitProps,
} from "solid-js";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import cx from "classnames"; import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
@@ -14,22 +21,22 @@ import styles from "./MachineTags.module.css";
export interface MachineTag { export interface MachineTag {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
new?: boolean;
} }
export type MachineTagsProps = FieldProps & { export type MachineTagsProps = FieldProps & {
name: string; name: string;
input: ComponentProps<"select">; onChange: (values: string[]) => void;
defaultValue?: string[];
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
defaultValue?: string[];
defaultOptions?: string[]; defaultOptions?: string[];
readonlyOptions?: string[]; readonlyOptions?: string[];
}; };
const uniqueOptions = (options: MachineTag[]) => { const uniqueOptions = (options: MachineTag[]) => {
const record: Record<string, MachineTag> = {}; const record: Record<string, MachineTag> = {};
console.log("uniqueOptions", options);
options.forEach((option) => { options.forEach((option) => {
// we want to preserve the first one we encounter // we want to preserve the first one we encounter
// this allows us to prefix the default 'all' tag // this allows us to prefix the default 'all' tag
@@ -41,11 +48,48 @@ const uniqueOptions = (options: MachineTag[]) => {
const sortedOptions = (options: MachineTag[]) => const sortedOptions = (options: MachineTag[]) =>
options.sort((a, b) => a.value.localeCompare(b.value)); options.sort((a, b) => a.value.localeCompare(b.value));
const sortedAndUniqueOptions = (options: MachineTag[]) => const sortedAndUniqueOptions = (options: MachineTag[]) => {
sortedOptions(uniqueOptions(options)); const r = sortedOptions(uniqueOptions(options));
console.log("sortedAndUniqueOptions", r);
return r;
};
// customises how each option is displayed in the dropdown export const MachineTags = (props: MachineTagsProps) => {
const ItemComponent = const [local, rest] = splitProps(props, ["defaultValue"]);
// // convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(local.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([
...(props.readonlyOptions || []).map((value) => ({
value,
disabled: true,
})),
...(props.defaultOptions || []).map((value) => ({ value })),
]),
);
const [selectedOptions, setSelectedOptions] =
createSignal<MachineTag[]>(defaultValue);
const handleToggle = (item: CollectionNode<MachineTag>) => () => {
setSelectedOptions((current) => {
const exists = current.find(
(option) => option.value === item.rawValue.value,
);
if (exists) {
return current.filter((option) => option.value !== item.rawValue.value);
}
return [...current, item.rawValue];
});
};
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => { (inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return ( return (
<Combobox.Item <Combobox.Item
@@ -53,6 +97,7 @@ const ItemComponent =
class={cx(styles.listboxItem, { class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted, [styles.listboxItemInverted]: inverted,
})} })}
onClick={handleToggle(props.item)}
> >
<Combobox.ItemLabel> <Combobox.ItemLabel>
<Typography <Typography
@@ -71,22 +116,7 @@ const ItemComponent =
); );
}; };
export const MachineTags = (props: MachineTagsProps) => { let selectRef: HTMLSelectElement;
// convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(props.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([
...(props.readonlyOptions || []).map((value) => ({
value,
disabled: true,
})),
...(props.defaultOptions || []).map((value) => ({ value })),
]),
);
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input // react when enter is pressed inside of the text input
@@ -96,21 +126,52 @@ export const MachineTags = (props: MachineTagsProps) => {
// get the current input value, exiting early if it's empty // get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement; const input = event.currentTarget as HTMLInputElement;
if (input.value === "") return; const trimmed = input.value.trim();
if (!trimmed) return;
setAvailableOptions((options) => { setAvailableOptions((curr) => {
return options.map((option) => { if (curr.find((option) => option.value === trimmed)) {
return { return curr;
...option, }
new: undefined, return [
}; ...curr,
{
value: trimmed,
},
];
}); });
setSelectedOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
return [
...curr,
{
value: trimmed,
},
];
}); });
// reset the input value selectRef.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
selectRef.dispatchEvent(
new Event("change", { bubbles: true, cancelable: true }),
);
input.value = ""; input.value = "";
} }
}; };
createEffect(() => {
console.log("availableOptions", availableOptions());
});
// Notify when selected options change
createEffect(
on(selectedOptions, (options) => {
console.log("selectedOptions", options);
props.onChange(options.map((o) => o.value));
}),
);
const align = () => { const align = () => {
if (props.readOnly) { if (props.readOnly) {
@@ -126,6 +187,7 @@ export const MachineTags = (props: MachineTagsProps) => {
class={cx("form-field", styles.machineTags, props.orientation)} class={cx("form-field", styles.machineTags, props.orientation)}
{...splitProps(props, ["defaultValue"])[1]} {...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue} defaultValue={defaultValue}
value={selectedOptions()}
options={availableOptions()} options={availableOptions()}
optionValue="value" optionValue="value"
optionTextValue="value" optionTextValue="value"
@@ -133,28 +195,8 @@ export const MachineTags = (props: MachineTagsProps) => {
optionDisabled="disabled" optionDisabled="disabled"
itemComponent={ItemComponent(props.inverted || false)} itemComponent={ItemComponent(props.inverted || false)}
placeholder="Enter a tag name" placeholder="Enter a tag name"
// triggerMode="focus" onChange={(val) => {
removeOnBackspace={false} console.log("Combobox onChange", val);
defaultFilter={() => true}
onInput={(event) => {
const input = event.target as HTMLInputElement;
// as the user types in the input box, we maintain a "new" option
// in the list of available options
setAvailableOptions((options) => {
return [
// remove the old "new" entry
...options.filter((option) => !option.new),
// add the updated "new" entry
{ value: input.value, new: true },
];
});
}}
onBlur={() => {
// clear the in-progress "new" option from the list of available options
setAvailableOptions((options) => {
return options.filter((option) => !option.new);
});
}} }}
> >
<Orienter orientation={props.orientation} align={align()}> <Orienter orientation={props.orientation} align={align()}>
@@ -164,7 +206,12 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props} {...props}
/> />
<Combobox.HiddenSelect {...props.input} multiple /> <Combobox.HiddenSelect
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.Control<MachineTag> <Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)} class={cx(styles.control, props.orientation)}
@@ -187,7 +234,13 @@ export const MachineTags = (props: MachineTagsProps) => {
icon={"Close"} icon={"Close"}
size="0.5rem" size="0.5rem"
inverted={inverted} inverted={inverted}
onClick={() => state.remove(option)} onClick={() =>
setSelectedOptions((curr) => {
return curr.filter(
(o) => o.value !== option.value,
);
})
}
/> />
) )
} }
@@ -220,7 +273,6 @@ export const MachineTags = (props: MachineTagsProps) => {
)} )}
</Combobox.Control> </Combobox.Control>
</Orienter> </Orienter>
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content <Combobox.Content
class={cx(styles.comboboxContent, { class={cx(styles.comboboxContent, {

View File

@@ -13,6 +13,7 @@ import * as v from "valibot";
import { splitProps } from "solid-js"; import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags"; import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
type Story = StoryObj<SidebarPaneProps>; type Story = StoryObj<SidebarPaneProps>;
@@ -137,18 +138,21 @@ export const Default: Story = {
console.log("saving tags", values); console.log("saving tags", values);
}} }}
> >
{({ editing, Field }) => ( {({ editing, Field, formStore }) => (
<Field name="tags" type="string[]"> <Field name="tags" type="string[]">
{(field, input) => ( {(field, props) => (
<MachineTags <MachineTags
{...splitProps(field, ["value"])[1]} {...splitProps(field, ["value"])[1]}
size="s" size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted inverted
required required
readOnly={!editing} readOnly={!editing}
orientation="horizontal" orientation="horizontal"
defaultValue={field.value} defaultValue={field.value}
input={input}
/> />
)} )}
</Field> </Field>

View File

@@ -2,6 +2,7 @@ import { createSignal, JSX, Show } from "solid-js";
import { import {
createForm, createForm,
FieldValues, FieldValues,
FormStore,
getErrors, getErrors,
Maybe, Maybe,
PartialValues, PartialValues,
@@ -25,6 +26,7 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
children: (ctx: { children: (ctx: {
editing: boolean; editing: boolean;
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"]; Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
formStore: FormStore<FormValues>;
}) => JSX.Element; }) => JSX.Element;
} }
@@ -51,6 +53,8 @@ export function SidebarSectionForm<
}; };
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => { const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
console.log("Submitting SidebarForm", values);
await props.onSubmit(values); await props.onSubmit(values);
setEditing(false); setEditing(false);
}; };
@@ -109,7 +113,7 @@ export function SidebarSectionForm<
</Typography> </Typography>
</div> </div>
</Show> </Show>
{props.children({ editing: editing(), Field })} {props.children({ editing: editing(), Field, formStore })}
</div> </div>
</div> </div>
</Form> </Form>

View File

@@ -20,13 +20,14 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const sections = () => { const Sections = () => {
const machineName = useMachineName(); const machineName = useMachineName();
const machineQuery = useMachineQuery(clanURI, machineName); const machineQuery = useMachineQuery(clanURI, machineName);
// we have to update the whole machine model rather than just the sub fields that were changed // we have to update the whole machine model rather than just the sub fields that were changed
// for that reason we pass in this common submit handler to each machine sub section // for that reason we pass in this common submit handler to each machine sub section
const onSubmit = async (values: Partial<MachineModel>) => { const onSubmit = async (values: Partial<MachineModel>) => {
console.log("saving tags", values);
const call = callApi("set_machine", { const call = callApi("set_machine", {
machine: { machine: {
name: machineName, name: machineName,
@@ -78,7 +79,7 @@ export const Machine = (props: RouteSectionProps) => {
</Show> </Show>
} }
> >
{sections()} <Sections />
</SidebarPane> </SidebarPane>
</div> </div>
</Show> </Show>

View File

@@ -5,6 +5,7 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
import { pick } from "@/src/util"; import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { MachineTags } from "@/src/components/Form/MachineTags"; import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
const schema = v.object({ const schema = v.object({
tags: v.pipe(v.optional(v.array(v.string()))), tags: v.pipe(v.optional(v.array(v.string()))),
@@ -32,7 +33,7 @@ export const SectionTags = (props: SectionTags) => {
const options = () => { const options = () => {
if (!machineQuery.isSuccess) { if (!machineQuery.isSuccess) {
return [[], []]; return [];
} }
// these are static values or values which have been configured in nix and // these are static values or values which have been configured in nix and
@@ -58,7 +59,7 @@ export const SectionTags = (props: SectionTags) => {
onSubmit={props.onSubmit} onSubmit={props.onSubmit}
initialValues={initialValues()} initialValues={initialValues()}
> >
{({ editing, Field }) => ( {({ editing, Field, formStore }) => (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<Field name="tags" type="string[]"> <Field name="tags" type="string[]">
{(field, input) => ( {(field, input) => (
@@ -72,7 +73,10 @@ export const SectionTags = (props: SectionTags) => {
defaultValue={field.value} defaultValue={field.value}
defaultOptions={options()[0]} defaultOptions={options()[0]}
readonlyOptions={options()[1]} readonlyOptions={options()[1]}
input={input} onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
/> />
)} )}
</Field> </Field>

View File

@@ -1,7 +1,12 @@
import { BackButton, StepLayout } from "@/src/workflows/Steps"; import { BackButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot"; import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper"; import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid"; import {
createForm,
setValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { import {
AddMachineSteps, AddMachineSteps,
AddMachineStoreType, AddMachineStoreType,
@@ -78,9 +83,12 @@ export const StepTags = (props: { onDone: () => void }) => {
{...field} {...field}
required required
orientation="horizontal" orientation="horizontal"
defaultValue={field.value} defaultValue={field.value || []}
defaultOptions={[]} defaultOptions={[]}
input={input} onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
/> />
)} )}
</Field> </Field>