Compare commits
15 Commits
feat/snaps
...
fix/combob
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3699c9da66 | ||
|
|
34e1a322d0 | ||
|
|
1b60a04de6 | ||
|
|
a079fb247d | ||
|
|
fbcfa4c12e | ||
|
|
8f4ff5367f | ||
|
|
43f9fce359 | ||
|
|
886d09e3f6 | ||
|
|
de8e62694c | ||
|
|
82a1767a98 | ||
|
|
f0f536dd84 | ||
|
|
00a5acc033 | ||
|
|
acbc8dcfb6 | ||
|
|
283fa31649 | ||
|
|
045332ba5e |
@@ -12,12 +12,20 @@ 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 {
|
||||
Accessor,
|
||||
Component,
|
||||
ComponentProps,
|
||||
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;
|
||||
input?: ComponentProps<"select">;
|
||||
itemControl?: Component<ComboboxControlState<Option>>;
|
||||
};
|
||||
|
||||
@@ -129,6 +137,7 @@ export const Combobox = <Option, OptGroup = never>(
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<KCombobox.HiddenSelect {...props.input} />
|
||||
<KCombobox.Control<Option> class="control">
|
||||
{(state) => {
|
||||
const [controlProps] = splitProps(props, [
|
||||
|
||||
221
pkgs/clan-app/ui/src/components/Form/MachineTags.css
Normal file
221
pkgs/clan-app/ui/src/components/Form/MachineTags.css
Normal file
@@ -0,0 +1,221 @@
|
||||
div.form-field.machine-tags {
|
||||
div.control {
|
||||
@apply flex flex-col size-full gap-2;
|
||||
|
||||
div.selected-options {
|
||||
@apply flex flex-wrap gap-2 size-full min-h-5;
|
||||
}
|
||||
|
||||
div.input-container {
|
||||
@apply relative left-0 top-0;
|
||||
@apply inline-flex justify-between w-full;
|
||||
|
||||
input {
|
||||
@apply w-full px-2 py-1.5 rounded-sm;
|
||||
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: "Archivo", sans-serif;
|
||||
line-height: 1;
|
||||
|
||||
&::placeholder {
|
||||
@apply fg-def-4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-def-acc-1 outline-def-acc-2;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@apply bg-def-1 outline-def-acc-3;
|
||||
|
||||
box-shadow:
|
||||
0 0 0 0.125rem theme(colors.bg.def.1),
|
||||
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||
}
|
||||
|
||||
&[data-invalid] {
|
||||
@apply outline-semantic-error-4;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
@apply outline-def-2 fg-def-4 cursor-not-allowed;
|
||||
}
|
||||
|
||||
&[data-readonly] {
|
||||
@apply outline-none border-none bg-inherit;
|
||||
@apply p-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
& > button.trigger {
|
||||
@apply flex items-center justify-center w-8;
|
||||
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
|
||||
|
||||
&[data-disabled] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
& > span.icon {
|
||||
@apply h-full w-full py-0.5 px-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
@apply flex-row gap-2 justify-between;
|
||||
|
||||
div.control {
|
||||
@apply w-1/2 grow;
|
||||
}
|
||||
}
|
||||
|
||||
&.s {
|
||||
div.control > div.input-container {
|
||||
& > input {
|
||||
@apply px-1.5 py-1;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&[data-readonly] {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
|
||||
& > button.trigger {
|
||||
@apply top-[0.1875rem] h-4 w-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.inverted {
|
||||
div.control > div.input-container {
|
||||
& > button.trigger {
|
||||
@apply bg-inv-2;
|
||||
}
|
||||
|
||||
& > input {
|
||||
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
|
||||
|
||||
&::placeholder {
|
||||
@apply fg-inv-4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-inv-acc-2 outline-inv-acc-2;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@apply bg-inv-acc-4;
|
||||
box-shadow:
|
||||
0 0 0 0.125rem theme(colors.bg.inv.1),
|
||||
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||
}
|
||||
|
||||
&[data-invalid] {
|
||||
@apply outline-semantic-error-4;
|
||||
}
|
||||
|
||||
&[data-readonly] {
|
||||
@apply outline-none border-none bg-inherit cursor-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
div.control > div.input-container {
|
||||
& > input {
|
||||
@apply outline-none;
|
||||
|
||||
&:hover {
|
||||
@apply outline-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.machine-tags-content {
|
||||
@apply rounded-sm bg-def-1 border border-def-2;
|
||||
|
||||
transform-origin: var(--kb-combobox-content-transform-origin);
|
||||
animation: machineTagsContentHide 250ms ease-in forwards;
|
||||
|
||||
&[data-expanded] {
|
||||
animation: machineTagsContentShow 250ms ease-out;
|
||||
}
|
||||
|
||||
& > ul.listbox {
|
||||
overflow-y: auto;
|
||||
max-height: 360px;
|
||||
|
||||
@apply px-2 py-3;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
li.item {
|
||||
@apply flex items-center justify-between;
|
||||
@apply relative px-2 py-1;
|
||||
@apply select-none outline-none rounded-[0.25rem];
|
||||
|
||||
color: hsl(240 4% 16%);
|
||||
height: 32px;
|
||||
|
||||
&[data-disabled] {
|
||||
color: hsl(240 5% 65%);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
@apply outline-none bg-def-4;
|
||||
}
|
||||
}
|
||||
|
||||
.item-indicator {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.machine-tags-control {
|
||||
@apply flex flex-col w-full gap-2;
|
||||
|
||||
& > div.selected-options {
|
||||
@apply flex gap-2 flex-wrap w-full;
|
||||
}
|
||||
|
||||
& > div.input-container {
|
||||
@apply w-full flex gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes machineTagsContentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes machineTagsContentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
192
pkgs/clan-app/ui/src/components/Form/MachineTags.tsx
Normal file
192
pkgs/clan-app/ui/src/components/Form/MachineTags.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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.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 = (props.defaultValue || []).map((value) => ({ value }));
|
||||
|
||||
// controlled values for selected and available options
|
||||
const [selectedOptions, setSelectedOptions] = createSignal<MachineTag[]>(
|
||||
sortedAndUniqueOptions([...staticOptions, ...defaultValue]),
|
||||
);
|
||||
|
||||
// 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 addOptionAndSelect = (option: MachineTag) => {
|
||||
// add to the list of available options first
|
||||
setAvailableOptions(
|
||||
sortedAndUniqueOptions([...availableOptions(), option]),
|
||||
);
|
||||
|
||||
// update the selected options
|
||||
setSelectedOptions(sortedAndUniqueOptions([...selectedOptions(), option]));
|
||||
};
|
||||
|
||||
const removeSelectedOption = (option: MachineTag) => {
|
||||
setSelectedOptions(
|
||||
selectedOptions().filter((o) => o.value !== option.value),
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// add the input value to the selected options
|
||||
addOptionAndSelect({ value: input.value });
|
||||
|
||||
// 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]}
|
||||
value={selectedOptions()}
|
||||
options={availableOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="value"
|
||||
optionLabel="value"
|
||||
optionDisabled="disabled"
|
||||
itemComponent={ItemComponent}
|
||||
placeholder="Enter a tag name"
|
||||
>
|
||||
<Orienter orientation={props.orientation} align={align()}>
|
||||
<Label
|
||||
labelComponent={Combobox.Label}
|
||||
descriptionComponent={Combobox.Description}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<Combobox.HiddenSelect {...props.input} multiple />
|
||||
|
||||
<Combobox.Control<MachineTag> class="control">
|
||||
<div class="selected-options">
|
||||
<For each={selectedOptions()}>
|
||||
{(option) => (
|
||||
<Tag
|
||||
label={option.value}
|
||||
inverted={props.inverted}
|
||||
action={
|
||||
option.disabled || props.disabled || props.readOnly
|
||||
? undefined
|
||||
: {
|
||||
icon: "Close",
|
||||
onClick: () => removeSelectedOption(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
|
||||
|
||||
@@ -3,6 +3,8 @@ import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
||||
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||
import { Show } from "solid-js";
|
||||
import { SectionGeneral } from "./SectionGeneral";
|
||||
import { useMachineQuery } from "@/src/hooks/queries";
|
||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,13 +15,21 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
navigateToClan(navigate, clanURI);
|
||||
};
|
||||
|
||||
const machineName = useMachineName();
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={useMachineName()} keyed>
|
||||
<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