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 { 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 cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
@@ -14,22 +21,22 @@ import styles from "./MachineTags.module.css";
export interface MachineTag {
value: string;
disabled?: boolean;
new?: boolean;
}
export type MachineTagsProps = FieldProps & {
name: string;
input: ComponentProps<"select">;
onChange: (values: string[]) => void;
defaultValue?: string[];
readOnly?: boolean;
disabled?: boolean;
required?: boolean;
defaultValue?: string[];
defaultOptions?: string[];
readonlyOptions?: string[];
};
const uniqueOptions = (options: MachineTag[]) => {
const record: Record<string, MachineTag> = {};
console.log("uniqueOptions", options);
options.forEach((option) => {
// we want to preserve the first one we encounter
// this allows us to prefix the default 'all' tag
@@ -41,40 +48,18 @@ const uniqueOptions = (options: MachineTag[]) => {
const sortedOptions = (options: MachineTag[]) =>
options.sort((a, b) => a.value.localeCompare(b.value));
const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options));
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
>
<Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
inverted={inverted}
>
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class={styles.itemIndicator}>
<Icon icon="Checkmark" inverted={inverted} />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
const sortedAndUniqueOptions = (options: MachineTag[]) => {
const r = sortedOptions(uniqueOptions(options));
console.log("sortedAndUniqueOptions", r);
return r;
};
export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const [local, rest] = splitProps(props, ["defaultValue"]);
// // convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(props.defaultValue || []).map((value) => ({ value })),
(local.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
@@ -88,6 +73,51 @@ export const MachineTags = (props: MachineTagsProps) => {
]),
);
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> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
onClick={handleToggle(props.item)}
>
<Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
inverted={inverted}
>
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class={styles.itemIndicator}>
<Icon icon="Checkmark" inverted={inverted} />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
let selectRef: HTMLSelectElement;
const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input
if (event.key === "Enter") {
@@ -96,21 +126,52 @@ export const MachineTags = (props: MachineTagsProps) => {
// get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement;
if (input.value === "") return;
const trimmed = input.value.trim();
if (!trimmed) return;
setAvailableOptions((options) => {
return options.map((option) => {
return {
...option,
new: undefined,
};
});
setAvailableOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
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 = "";
}
};
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 = () => {
if (props.readOnly) {
@@ -126,6 +187,7 @@ export const MachineTags = (props: MachineTagsProps) => {
class={cx("form-field", styles.machineTags, props.orientation)}
{...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue}
value={selectedOptions()}
options={availableOptions()}
optionValue="value"
optionTextValue="value"
@@ -133,28 +195,8 @@ export const MachineTags = (props: MachineTagsProps) => {
optionDisabled="disabled"
itemComponent={ItemComponent(props.inverted || false)}
placeholder="Enter a tag name"
// triggerMode="focus"
removeOnBackspace={false}
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);
});
onChange={(val) => {
console.log("Combobox onChange", val);
}}
>
<Orienter orientation={props.orientation} align={align()}>
@@ -164,7 +206,12 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props}
/>
<Combobox.HiddenSelect {...props.input} multiple />
<Combobox.HiddenSelect
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)}
@@ -187,7 +234,13 @@ export const MachineTags = (props: MachineTagsProps) => {
icon={"Close"}
size="0.5rem"
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>
</Orienter>
<Combobox.Portal>
<Combobox.Content
class={cx(styles.comboboxContent, {

View File

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

View File

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

View File

@@ -20,13 +20,14 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI);
};
const sections = () => {
const Sections = () => {
const machineName = useMachineName();
const machineQuery = useMachineQuery(clanURI, machineName);
// 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
const onSubmit = async (values: Partial<MachineModel>) => {
console.log("saving tags", values);
const call = callApi("set_machine", {
machine: {
name: machineName,
@@ -78,7 +79,7 @@ export const Machine = (props: RouteSectionProps) => {
</Show>
}
>
{sections()}
<Sections />
</SidebarPane>
</div>
</Show>

View File

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

View File

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