Merge pull request 'wip(ui): MachineTags component and tags section in machine detail pane' (#4560) from ui/machine-tags into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4560
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 {
|
div.control {
|
||||||
@apply flex flex-col size-full gap-2;
|
@apply flex flex-col size-full gap-2;
|
||||||
|
|
||||||
div.selected-options {
|
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 {
|
div.input-container {
|
||||||
@@ -137,14 +137,14 @@ div.form-field.combobox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.combobox-content {
|
div.machine-tags-content {
|
||||||
@apply rounded-sm bg-def-1 border border-def-2;
|
@apply rounded-sm bg-def-1 border border-def-2 z-10;
|
||||||
|
|
||||||
transform-origin: var(--kb-combobox-content-transform-origin);
|
transform-origin: var(--kb-combobox-content-transform-origin);
|
||||||
animation: comboboxContentHide 250ms ease-in forwards;
|
animation: machineTagsContentHide 250ms ease-in forwards;
|
||||||
|
|
||||||
&[data-expanded] {
|
&[data-expanded] {
|
||||||
animation: comboboxContentShow 250ms ease-out;
|
animation: machineTagsContentShow 250ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > ul.listbox {
|
& > ul.listbox {
|
||||||
@@ -186,7 +186,7 @@ div.combobox-content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.combobox-control {
|
div.machine-tags-control {
|
||||||
@apply flex flex-col w-full gap-2;
|
@apply flex flex-col w-full gap-2;
|
||||||
|
|
||||||
& > div.selected-options {
|
& > div.selected-options {
|
||||||
@@ -198,7 +198,7 @@ div.combobox-control {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes comboboxContentShow {
|
@keyframes machineTagsContentShow {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
@@ -209,7 +209,7 @@ div.combobox-control {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes comboboxContentHide {
|
@keyframes machineTagsContentHide {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -48,9 +48,6 @@ export const TextArea = (props: TextAreaProps) => {
|
|||||||
// Update the height
|
// Update the height
|
||||||
textareaRef.style.height = `${newHeight}px`;
|
textareaRef.style.height = `${newHeight}px`;
|
||||||
textareaRef.style.maxHeight = `${maxHeight}px`;
|
textareaRef.style.maxHeight = `${maxHeight}px`;
|
||||||
|
|
||||||
console.log("min/max height", minHeight, maxHeight);
|
|
||||||
console.log("textarea ref style", textareaRef.style);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up auto-resize effect
|
// Set up auto-resize effect
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { createSignal, Show } from "solid-js";
|
|||||||
import { SectionGeneral } from "./SectionGeneral";
|
import { SectionGeneral } from "./SectionGeneral";
|
||||||
import { InstallModal } from "@/src/workflows/Install/install";
|
import { InstallModal } from "@/src/workflows/Install/install";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
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) => {
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -16,6 +18,17 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [showInstall, setShowModal] = createSignal(false);
|
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;
|
let container: Node;
|
||||||
return (
|
return (
|
||||||
@@ -39,9 +52,7 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<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