Compare commits

...

15 Commits

Author SHA1 Message Date
Brian McGee
3699c9da66 wip 2025-07-31 17:14:10 +01:00
Brian McGee
34e1a322d0 wip 2025-07-31 17:13:02 +01:00
Brian McGee
1b60a04de6 wip 2025-07-31 17:12:10 +01:00
Brian McGee
a079fb247d wip 2025-07-31 16:33:13 +01:00
Brian McGee
fbcfa4c12e wip 2025-07-31 16:11:47 +01:00
Brian McGee
8f4ff5367f wip 2025-07-31 16:11:04 +01:00
Brian McGee
43f9fce359 wip 2025-07-31 15:48:45 +01:00
Brian McGee
886d09e3f6 wip 2025-07-31 15:39:01 +01:00
Brian McGee
de8e62694c wip 2025-07-31 15:29:41 +01:00
Brian McGee
82a1767a98 wip 2025-07-31 15:26:06 +01:00
Brian McGee
f0f536dd84 wip 2025-07-31 15:22:16 +01:00
Brian McGee
00a5acc033 wip 2025-07-31 12:15:14 +01:00
Brian McGee
acbc8dcfb6 wip 2025-07-31 12:12:35 +01:00
Brian McGee
283fa31649 wip 2025-07-31 12:05:24 +01:00
Brian McGee
045332ba5e wip 2025-07-31 11:28:14 +01:00
7 changed files with 575 additions and 52 deletions

View File

@@ -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, [

View 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);
}
}

View 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>
);
};

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

@@ -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>
);
};

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>
);
};