feat(ui): MachineTags component and tags section in machine detail pane

This commit is contained in:
Brian McGee
2025-07-31 11:28:14 +01:00
parent 1373670dfc
commit e0e16de144
8 changed files with 367 additions and 376 deletions

View File

@@ -1,136 +0,0 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { Combobox, ComboboxProps } from "./Combobox";
const ComboboxExamples = (props: ComboboxProps<string>) => (
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 p-8">
<Combobox {...props} />
<Combobox {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Combobox {...props} inverted={true} />
<Combobox {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<Combobox {...props} orientation="horizontal" />
<Combobox {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<Combobox {...props} inverted={true} orientation="horizontal" />
<Combobox {...props} inverted={true} orientation="horizontal" size="s" />
</div>
</div>
);
const meta = {
title: "Components/Form/Combobox",
component: ComboboxExamples,
decorators: [
(Story: StoryObj, context: StoryContext<ComboboxProps<string>>) => {
return (
<div
class={cx({
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
"w-[1024px]": context.args.orientation == "horizontal",
"bg-inv-acc-3": context.args.inverted,
})}
>
<Story />
</div>
);
},
],
} satisfies Meta<ComboboxProps<string>>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
options: ["foo", "bar", "baz"],
defaultValue: "foo",
},
};
export const Label: Story = {
args: {
...Bare.args,
label: "DOB",
},
};
export const Description: Story = {
args: {
...Label.args,
description: "The date you were born",
},
};
export const Required: Story = {
args: {
...Description.args,
required: true,
},
};
export const Multiple: Story = {
args: {
...Description.args,
required: true,
multiple: true,
defaultValue: ["foo", "bar"],
},
};
export const Tooltip: Story = {
args: {
...Required.args,
tooltip: "The day you came out of your momma",
},
};
export const Ghost: Story = {
args: {
...Tooltip.args,
ghost: true,
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
validationState: "invalid",
},
};
export const Disabled: Story = {
args: {
...Tooltip.args,
disabled: true,
},
};
export const MultipleDisabled: Story = {
args: {
...Multiple.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Tooltip.args,
readOnly: true,
defaultValue: "foo",
},
};
export const MultipleReadonly: Story = {
args: {
...Multiple.args,
readOnly: true,
},
};

View File

@@ -1,181 +0,0 @@
import Icon from "@/src/components/Icon/Icon";
import {
Combobox as KCombobox,
ComboboxRootOptions as KComboboxRootOptions,
} from "@kobalte/core/combobox";
import { isFunction } from "@kobalte/utils";
import "./Combobox.css";
import { CollectionNode } from "@kobalte/core";
import { Label } from "./Label";
import cx from "classnames";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Typography } from "@/src/components/Typography/Typography";
import { Accessor, Component, For, Show, splitProps } from "solid-js";
import { Tag } from "@/src/components/Tag/Tag";
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
KComboboxRootOptions<Option, OptGroup> & {
inverted: boolean;
itemControl?: Component<ComboboxControlState<Option>>;
};
export const DefaultItemComponent = <Option,>(
props: ComboboxItemComponentProps<Option>,
) => {
return (
<ComboboxItem item={props.item} class="item">
<ComboboxItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</ComboboxItemLabel>
<ComboboxItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</ComboboxItemIndicator>
</ComboboxItem>
);
};
// adapted from https://github.com/kobaltedev/kobalte/blob/98a4810903c0c425d28cef4f0d1984192a225788/packages/core/src/combobox/combobox-base.tsx#L439
const getOptionTextValue = <Option,>(
option: Option,
optionTextValue:
| keyof Exclude<Option, null>
| ((option: Exclude<Option, null>) => string)
| undefined,
) => {
if (optionTextValue == null) {
// If no `optionTextValue`, the option itself is the label (ex: string[] of options).
return String(option);
}
// Get the label from the option object as a string.
return String(
isFunction(optionTextValue)
? optionTextValue(option as never)
: (option as never)[optionTextValue],
);
};
export const DefaultItemControl = <Option,>(
props: ComboboxControlState<Option>,
) => (
<>
<Show when={props.multiple}>
<div class="selected-options">
<For each={props.selectedOptions()}>
{(option) => (
<Tag
inverted={props.inverted}
label={getOptionTextValue<Option>(option, props.optionTextValue)}
action={
props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => props.remove(option),
}
}
/>
)}
</For>
</div>
</Show>
{!(props.readOnly && props.multiple) && (
<div class="input-container">
<KCombobox.Input />
{!props.readOnly && (
<KCombobox.Trigger class="trigger">
<KCombobox.Icon class="icon">
<Icon icon="Expand" inverted={props.inverted} size="100%" />
</KCombobox.Icon>
</KCombobox.Trigger>
)}
</div>
)}
</>
);
// todo aria-label on combobox.control and combobox.input
export const Combobox = <Option, OptGroup = never>(
props: ComboboxProps<Option, OptGroup>,
) => {
const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent;
const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return (
<KCombobox
class={cx("form-field", "combobox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
itemComponent={itemComponent()}
>
<Orienter orientation={props.orientation} align={align()}>
<Label
labelComponent={KCombobox.Label}
descriptionComponent={KCombobox.Description}
{...props}
/>
<KCombobox.Control<Option> class="control">
{(state) => {
const [controlProps] = splitProps(props, [
"inverted",
"multiple",
"readOnly",
"disabled",
]);
return itemControl()({ ...state, ...controlProps });
}}
</KCombobox.Control>
<KCombobox.Portal>
<KCombobox.Content class="combobox-content">
<KCombobox.Listbox class="listbox" />
</KCombobox.Content>
</KCombobox.Portal>
</Orienter>
</KCombobox>
);
};
// todo can we replicate the . notation that Kobalte achieves with their type definitions?
export const ComboboxItem = KCombobox.Item;
export const ComboboxItemDescription = KCombobox.ItemDescription;
export const ComboboxItemIndicator = KCombobox.ItemIndicator;
export const ComboboxItemLabel = KCombobox.ItemLabel;
// these interfaces were not exported, so we re-declare them
export interface ComboboxItemComponentProps<Option> {
/** The item to render. */
item: CollectionNode<Option>;
}
export interface ComboboxSectionComponentProps<OptGroup> {
/** The section to render. */
section: CollectionNode<OptGroup>;
}
type ComboboxControlState<Option> = Pick<
ComboboxProps<Option>,
"optionTextValue" | "inverted" | "multiple" | "size" | "readOnly" | "disabled"
> & {
/** The selected options. */
selectedOptions: Accessor<Option[]>;
/** A function to remove an option from the selection. */
remove: (option: Option) => void;
/** A function to clear the selection. */
clear: () => void;
};

View File

@@ -1,9 +1,9 @@
div.form-field.combobox {
div.form-field.machine-tags {
div.control {
@apply flex flex-col size-full gap-2;
div.selected-options {
@apply flex flex-wrap gap-1 size-full min-h-5;
@apply flex flex-wrap gap-2 size-full min-h-5;
}
div.input-container {
@@ -137,14 +137,14 @@ div.form-field.combobox {
}
}
div.combobox-content {
@apply rounded-sm bg-def-1 border border-def-2;
div.machine-tags-content {
@apply rounded-sm bg-def-1 border border-def-2 z-10;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: comboboxContentHide 250ms ease-in forwards;
animation: machineTagsContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: comboboxContentShow 250ms ease-out;
animation: machineTagsContentShow 250ms ease-out;
}
& > ul.listbox {
@@ -186,7 +186,7 @@ div.combobox-content {
}
}
div.combobox-control {
div.machine-tags-control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
@@ -198,7 +198,7 @@ div.combobox-control {
}
}
@keyframes comboboxContentShow {
@keyframes machineTagsContentShow {
from {
opacity: 0;
transform: translateY(-8px);
@@ -209,7 +209,7 @@ div.combobox-control {
}
}
@keyframes comboboxContentHide {
@keyframes machineTagsContentHide {
from {
opacity: 1;
transform: translateY(0);

View File

@@ -0,0 +1,206 @@
import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field";
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
import Icon from "../Icon/Icon";
import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
import { Tag } from "@/src/components/Tag/Tag";
import "./MachineTags.css";
import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter";
import { CollectionNode } from "@kobalte/core";
interface MachineTag {
value: string;
disabled?: boolean;
new?: boolean;
}
export type MachineTagsProps = FieldProps & {
name: string;
input: ComponentProps<"select">;
readOnly?: boolean;
disabled?: boolean;
required?: boolean;
defaultValue?: string[];
};
// tags which are applied to all machines and cannot be removed
const staticOptions = [{ value: "all", disabled: true }];
const uniqueOptions = (options: MachineTag[]) => {
const record: Record<string, MachineTag> = {};
options.forEach((option) => {
// we want to preserve the first one we encounter
// this allows us to prefix the default 'all' tag
record[option.value] = record[option.value] || option;
});
return Object.values(record);
};
const sortedOptions = (options: MachineTag[]) => {
return options.sort((a, b) => {
if (a.new && !b.new) return -1;
if (a.disabled && !b.disabled) return -1;
return a.value.localeCompare(b.value);
});
};
const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options));
// customises how each option is displayed in the dropdown
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item item={props.item} class="item">
<Combobox.ItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(props.defaultValue || []).map((value) => ({ value })),
);
// todo this should be the superset of tags used across the entire clan and be passed in via a prop
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([...staticOptions, ...defaultValue]),
);
const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
// get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement;
if (input.value === "") return;
setAvailableOptions((options) => {
return options.map((option) => {
return {
...option,
new: undefined,
};
});
});
// reset the input value
input.value = "";
}
};
const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return (
<Combobox<MachineTag>
multiple
class={cx("form-field", "machine-tags", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue}
options={availableOptions()}
optionValue="value"
optionTextValue="value"
optionLabel="value"
optionDisabled="disabled"
itemComponent={ItemComponent}
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);
});
}}
>
<Orienter orientation={props.orientation} align={align()}>
<Label
labelComponent={Combobox.Label}
descriptionComponent={Combobox.Description}
{...props}
/>
<Combobox.HiddenSelect {...props.input} multiple />
<Combobox.Control<MachineTag> class="control">
{(state) => (
<div class="selected-options">
<For each={state.selectedOptions()}>
{(option) => (
<Tag
label={option.value}
inverted={props.inverted}
action={
option.disabled || props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => state.remove(option),
}
}
/>
)}
</For>
<Show when={!props.readOnly}>
<div class="input-container">
<Combobox.Input onKeyDown={onKeyDown} />
<Combobox.Trigger class="trigger">
<Combobox.Icon class="icon">
<Icon
icon="Expand"
inverted={!props.inverted}
size="100%"
/>
</Combobox.Icon>
</Combobox.Trigger>
</div>
</Show>
</div>
)}
</Combobox.Control>
</Orienter>
<Combobox.Portal>
<Combobox.Content class="machine-tags-content">
<Combobox.Listbox class="listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
};

View File

@@ -12,26 +12,17 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
import * as v from "valibot";
import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags";
type Story = StoryObj<SidebarPaneProps>;
const schema = v.object({
firstName: v.pipe(v.string(), v.nonEmpty("Please enter a first name.")),
lastName: v.pipe(v.string(), v.nonEmpty("Please enter a last name.")),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
tags: v.pipe(v.optional(v.array(v.string()))),
});
const clanURI = "/home/brian/clans/my-clan";
const profiles = {
ron: {
firstName: "Ron",
lastName: "Burgundy",
bio: "It's actually an optical illusion, it's the pattern on the pants.",
shareProfile: true,
tags: ["All", "Home Server", "Backup", "Random"],
tags: ["all", "home Server", "backup", "random"],
},
};
@@ -52,7 +43,18 @@ export const Default: Story = {
<>
<SidebarSectionForm
title="General"
schema={schema}
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
@@ -125,33 +127,33 @@ export const Default: Story = {
</div>
)}
</SidebarSectionForm>
{/* todo fix tags component */}
{/*<SidebarSectionForm*/}
{/* title="Tags"*/}
{/* schema={schema}*/}
{/* initialValues={profiles.ron}*/}
{/* onSubmit={async () => {*/}
{/* console.log("saving general");*/}
{/* }}*/}
{/*>*/}
{/* {({ editing, Field }) => (*/}
{/* <Field name="tags">*/}
{/* {(field, input) => (*/}
{/* <Combobox*/}
{/* {...field}*/}
{/* value={field.value}*/}
{/* options={field.value || []}*/}
{/* size="s"*/}
{/* inverted*/}
{/* required*/}
{/* readOnly={!editing}*/}
{/* orientation="horizontal"*/}
{/* multiple*/}
{/* />*/}
{/* )}*/}
{/* </Field>*/}
{/* )}*/}
{/*</SidebarSectionForm>*/}
<SidebarSectionForm
title="Tags"
schema={v.object({
tags: v.pipe(v.array(v.string()), v.nonEmpty()),
})}
initialValues={profiles.ron}
onSubmit={async (values) => {
console.log("saving tags", values);
}}
>
{({ editing, Field }) => (
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</Field>
)}
</SidebarSectionForm>
<SidebarSection title="Simple" class="flex flex-col">
<Typography tag="h2" hierarchy="title" size="m" inverted>
Static Content

View File

@@ -5,6 +5,8 @@ import { createSignal, Show } from "solid-js";
import { SectionGeneral } from "./SectionGeneral";
import { InstallModal } from "@/src/workflows/Install/install";
import { Button } from "@/src/components/Button/Button";
import { useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags";
export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate();
@@ -16,6 +18,17 @@ export const Machine = (props: RouteSectionProps) => {
};
const [showInstall, setShowModal] = createSignal(false);
const sidebarPane = (machineName: string) => {
const machineQuery = useMachineQuery(clanURI, machineName);
const sectionProps = { clanURI, machineName, machineQuery };
return (
<SidebarPane title={machineName} onClose={onClose}>
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
);
};
let container: Node;
return (
@@ -39,9 +52,7 @@ export const Machine = (props: RouteSectionProps) => {
/>
</div>
</Show>
<SidebarPane title={useMachineName()} onClose={onClose}>
<SectionGeneral />
</SidebarPane>
{sidebarPane(useMachineName())}
</Show>
);
};

View File

@@ -3,11 +3,11 @@ import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
import { Show, splitProps } from "solid-js";
import { useMachineQuery } from "@/src/hooks/queries";
import { useClanURI, useMachineName } from "@/src/hooks/clan";
import { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
const schema = v.object({
name: v.pipe(v.optional(v.string()), v.readonly()),
@@ -17,11 +17,14 @@ const schema = v.object({
type FormValues = v.InferInput<typeof schema>;
export const SectionGeneral = () => {
const clanURI = useClanURI();
const machineName = useMachineName();
export interface SectionGeneralProps {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
}
const machineQuery = useMachineQuery(clanURI, machineName);
export const SectionGeneral = (props: SectionGeneralProps) => {
const machineQuery = props.machineQuery;
const initialValues = () => {
if (!machineQuery.isSuccess) {
@@ -38,9 +41,9 @@ export const SectionGeneral = () => {
const onSubmit = async (values: FormValues) => {
const call = callApi("set_machine", {
machine: {
name: machineName,
name: props.machineName,
flake: {
identifier: clanURI,
identifier: props.clanURI,
},
},
update: {

View File

@@ -0,0 +1,86 @@
import * as v from "valibot";
import { Show, splitProps } from "solid-js";
import { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
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";
const schema = v.object({
tags: v.pipe(v.optional(v.array(v.string()))),
});
type FormValues = v.InferInput<typeof schema>;
export interface SectionTags {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
}
export const SectionTags = (props: SectionTags) => {
const machineQuery = props.machineQuery;
const initialValues = () => {
if (!machineQuery.isSuccess) {
return {};
}
return pick(machineQuery.data, ["tags"]) satisfies FormValues;
};
const onSubmit = async (values: FormValues) => {
console.log("submitting tags", values);
const call = callApi("set_machine", {
machine: {
name: props.machineName,
flake: {
identifier: props.clanURI,
},
},
update: {
...machineQuery.data,
...values,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(result.errors[0].message);
}
// refresh the query
await machineQuery.refetch();
};
return (
<Show when={machineQuery.isSuccess}>
<SidebarSectionForm
title="Tags"
schema={schema}
onSubmit={onSubmit}
initialValues={initialValues()}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</Field>
</div>
)}
</SidebarSectionForm>
</Show>
);
};