Compare commits
15 Commits
clan-25.05
...
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 { FieldProps } from "./Field";
|
||||||
import { Orienter } from "./Orienter";
|
import { Orienter } from "./Orienter";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
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";
|
import { Tag } from "@/src/components/Tag/Tag";
|
||||||
|
|
||||||
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
|
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
|
||||||
KComboboxRootOptions<Option, OptGroup> & {
|
KComboboxRootOptions<Option, OptGroup> & {
|
||||||
inverted: boolean;
|
inverted: boolean;
|
||||||
|
input?: ComponentProps<"select">;
|
||||||
itemControl?: Component<ComboboxControlState<Option>>;
|
itemControl?: Component<ComboboxControlState<Option>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +137,7 @@ export const Combobox = <Option, OptGroup = never>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<KCombobox.HiddenSelect {...props.input} />
|
||||||
<KCombobox.Control<Option> class="control">
|
<KCombobox.Control<Option> class="control">
|
||||||
{(state) => {
|
{(state) => {
|
||||||
const [controlProps] = splitProps(props, [
|
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 * as v from "valibot";
|
||||||
import { splitProps } from "solid-js";
|
import { splitProps } from "solid-js";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||||
|
|
||||||
type Story = StoryObj<SidebarPaneProps>;
|
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 = {
|
const profiles = {
|
||||||
ron: {
|
ron: {
|
||||||
firstName: "Ron",
|
firstName: "Ron",
|
||||||
lastName: "Burgundy",
|
lastName: "Burgundy",
|
||||||
bio: "It's actually an optical illusion, it's the pattern on the pants.",
|
bio: "It's actually an optical illusion, it's the pattern on the pants.",
|
||||||
shareProfile: true,
|
shareProfile: true,
|
||||||
tags: ["All", "Home Server", "Backup", "Random"],
|
tags: ["all", "home Server", "backup", "random"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +43,18 @@ export const Default: Story = {
|
|||||||
<>
|
<>
|
||||||
<SidebarSectionForm
|
<SidebarSectionForm
|
||||||
title="General"
|
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}
|
initialValues={profiles.ron}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
console.log("saving general");
|
console.log("saving general");
|
||||||
@@ -125,33 +127,33 @@ export const Default: Story = {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SidebarSectionForm>
|
</SidebarSectionForm>
|
||||||
{/* todo fix tags component */}
|
<SidebarSectionForm
|
||||||
{/*<SidebarSectionForm*/}
|
title="Tags"
|
||||||
{/* title="Tags"*/}
|
schema={v.object({
|
||||||
{/* schema={schema}*/}
|
tags: v.pipe(v.array(v.string()), v.nonEmpty()),
|
||||||
{/* initialValues={profiles.ron}*/}
|
})}
|
||||||
{/* onSubmit={async () => {*/}
|
initialValues={profiles.ron}
|
||||||
{/* console.log("saving general");*/}
|
onSubmit={async (values) => {
|
||||||
{/* }}*/}
|
console.log("saving tags", values);
|
||||||
{/*>*/}
|
}}
|
||||||
{/* {({ editing, Field }) => (*/}
|
>
|
||||||
{/* <Field name="tags">*/}
|
{({ editing, Field }) => (
|
||||||
{/* {(field, input) => (*/}
|
<Field name="tags" type="string[]">
|
||||||
{/* <Combobox*/}
|
{(field, input) => (
|
||||||
{/* {...field}*/}
|
<MachineTags
|
||||||
{/* value={field.value}*/}
|
{...splitProps(field, ["value"])[1]}
|
||||||
{/* options={field.value || []}*/}
|
size="s"
|
||||||
{/* size="s"*/}
|
inverted
|
||||||
{/* inverted*/}
|
required
|
||||||
{/* required*/}
|
readOnly={!editing}
|
||||||
{/* readOnly={!editing}*/}
|
orientation="horizontal"
|
||||||
{/* orientation="horizontal"*/}
|
defaultValue={field.value}
|
||||||
{/* multiple*/}
|
input={input}
|
||||||
{/* />*/}
|
/>
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* </Field>*/}
|
</Field>
|
||||||
{/* )}*/}
|
)}
|
||||||
{/*</SidebarSectionForm>*/}
|
</SidebarSectionForm>
|
||||||
<SidebarSection title="Simple" class="flex flex-col">
|
<SidebarSection title="Simple" class="flex flex-col">
|
||||||
<Typography tag="h2" hierarchy="title" size="m" inverted>
|
<Typography tag="h2" hierarchy="title" size="m" inverted>
|
||||||
Static Content
|
Static Content
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
|||||||
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { SectionGeneral } from "./SectionGeneral";
|
import { SectionGeneral } from "./SectionGeneral";
|
||||||
|
import { useMachineQuery } from "@/src/hooks/queries";
|
||||||
|
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||||
|
|
||||||
export const Machine = (props: RouteSectionProps) => {
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -13,13 +15,21 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
navigateToClan(navigate, clanURI);
|
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 (
|
return (
|
||||||
<Show when={useMachineName()} keyed>
|
<Show when={useMachineName()} keyed>
|
||||||
<SidebarPane title={useMachineName()} onClose={onClose}>
|
{sidebarPane(useMachineName())}
|
||||||
<SectionGeneral />
|
|
||||||
</SidebarPane>
|
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { TextInput } from "@/src/components/Form/TextInput";
|
|||||||
import { Divider } from "@/src/components/Divider/Divider";
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
import { TextArea } from "@/src/components/Form/TextArea";
|
import { TextArea } from "@/src/components/Form/TextArea";
|
||||||
import { Show, splitProps } from "solid-js";
|
import { Show, splitProps } from "solid-js";
|
||||||
import { useMachineQuery } from "@/src/hooks/queries";
|
import { Machine } from "@/src/hooks/queries";
|
||||||
import { useClanURI, useMachineName } from "@/src/hooks/clan";
|
|
||||||
import { callApi } from "@/src/hooks/api";
|
import { callApi } from "@/src/hooks/api";
|
||||||
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
|
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
|
||||||
import { pick } from "@/src/util";
|
import { pick } from "@/src/util";
|
||||||
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = v.object({
|
||||||
name: v.pipe(v.optional(v.string()), v.readonly()),
|
name: v.pipe(v.optional(v.string()), v.readonly()),
|
||||||
@@ -17,11 +17,14 @@ const schema = v.object({
|
|||||||
|
|
||||||
type FormValues = v.InferInput<typeof schema>;
|
type FormValues = v.InferInput<typeof schema>;
|
||||||
|
|
||||||
export const SectionGeneral = () => {
|
export interface SectionGeneralProps {
|
||||||
const clanURI = useClanURI();
|
clanURI: string;
|
||||||
const machineName = useMachineName();
|
machineName: string;
|
||||||
|
machineQuery: UseQueryResult<Machine>;
|
||||||
|
}
|
||||||
|
|
||||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
export const SectionGeneral = (props: SectionGeneralProps) => {
|
||||||
|
const machineQuery = props.machineQuery;
|
||||||
|
|
||||||
const initialValues = () => {
|
const initialValues = () => {
|
||||||
if (!machineQuery.isSuccess) {
|
if (!machineQuery.isSuccess) {
|
||||||
@@ -38,9 +41,9 @@ export const SectionGeneral = () => {
|
|||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
const call = callApi("set_machine", {
|
const call = callApi("set_machine", {
|
||||||
machine: {
|
machine: {
|
||||||
name: machineName,
|
name: props.machineName,
|
||||||
flake: {
|
flake: {
|
||||||
identifier: clanURI,
|
identifier: props.clanURI,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
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