feat(ui): MachineTags component and tags section in machine detail pane
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
206
pkgs/clan-app/ui/src/components/Form/MachineTags.tsx
Normal file
206
pkgs/clan-app/ui/src/components/Form/MachineTags.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
86
pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx
Normal file
86
pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user