Merge pull request 'Clan Settings modal' (#4941) from ui/clan-settings-modal into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4941
This commit is contained in:
brianmcgee
2025-08-25 15:33:31 +00:00
16 changed files with 545 additions and 109 deletions

View File

@@ -24,6 +24,14 @@ export const Checkbox = (props: CheckboxProps) => {
// we need to separate output the input otherwise it interferes with prop binding // we need to separate output the input otherwise it interferes with prop binding
const [_, rootProps] = splitProps(props, ["input"]); const [_, rootProps] = splitProps(props, ["input"]);
const [styleProps, otherRootProps] = splitProps(rootProps, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
const alignment = () => const alignment = () =>
(props.orientation || "vertical") == "vertical" ? "start" : "center"; (props.orientation || "vertical") == "vertical" ? "start" : "center";
@@ -47,14 +55,21 @@ export const Checkbox = (props: CheckboxProps) => {
return ( return (
<KCheckbox.Root <KCheckbox.Root
class={cx("form-field", "checkbox", props.size, props.orientation, { class={cx(
inverted: props.inverted, styleProps.class,
ghost: props.ghost, "form-field",
})} "checkbox",
{...rootProps} styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherRootProps}
> >
{(state) => ( {(state) => (
<Orienter orientation={props.orientation} align={alignment()}> <Orienter orientation={styleProps.orientation} align={alignment()}>
<Label <Label
labelComponent={KCheckbox.Label} labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description} descriptionComponent={KCheckbox.Description}

View File

@@ -1,6 +1,7 @@
export interface FieldProps { export interface FieldProps {
class?: string; class?: string;
label?: string; label?: string;
labelWeight?: "bold" | "normal";
description?: string; description?: string;
tooltip?: string; tooltip?: string;
ghost?: boolean; ghost?: boolean;

View File

@@ -11,7 +11,7 @@ import styles from "./HostFileInput.module.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { createSignal } from "solid-js"; import { createSignal, splitProps } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip"; import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
@@ -40,17 +40,31 @@ export const HostFileInput = (props: HostFileInputProps) => {
} }
}; };
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return ( return (
<TextField <TextField
class={cx("form-field", props.size, props.orientation, { class={cx(
inverted: props.inverted, styleProps.class,
ghost: props.ghost, "form-field",
})} styleProps.size,
{...props} styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
> >
<Orienter <Orienter
orientation={props.orientation} orientation={styleProps.orientation}
align={props.orientation == "horizontal" ? "center" : "start"} align={styleProps.orientation == "horizontal" ? "center" : "start"}
> >
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
@@ -70,12 +84,12 @@ export const HostFileInput = (props: HostFileInputProps) => {
{!value() && ( {!value() && (
<Button <Button
hierarchy="secondary" hierarchy="secondary"
size={props.size} size={styleProps.size}
startIcon="Folder" startIcon="Folder"
onClick={selectFile} onClick={selectFile}
disabled={props.disabled || props.readOnly} disabled={props.disabled || props.readOnly}
class={cx( class={cx(
props.orientation === "vertical" styleProps.orientation === "vertical"
? styles.vertical_button ? styles.vertical_button
: styles.horizontal_button, : styles.horizontal_button,
)} )}
@@ -92,7 +106,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
hierarchy="body" hierarchy="body"
size="xs" size="xs"
weight="medium" weight="medium"
inverted={!props.inverted} inverted={!styleProps.inverted}
> >
{value()} {value()}
</Typography> </Typography>
@@ -107,7 +121,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
: styles.horizontal_button, : styles.horizontal_button,
)} )}
hierarchy="secondary" hierarchy="secondary"
size={props.size} size={styleProps.size}
startIcon="Folder" startIcon="Folder"
onClick={selectFile} onClick={selectFile}
disabled={props.disabled || props.readOnly} disabled={props.disabled || props.readOnly}

View File

@@ -27,6 +27,7 @@ export interface LabelProps {
descriptionComponent: DescriptionComponent; descriptionComponent: DescriptionComponent;
size?: Size; size?: Size;
label?: string; label?: string;
labelWeight?: "bold" | "normal";
description?: string; description?: string;
tooltip?: string; tooltip?: string;
icon?: string; icon?: string;
@@ -46,7 +47,7 @@ export const Label = (props: LabelProps) => {
hierarchy="label" hierarchy="label"
size={props.size || "default"} size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"} color={props.validationState == "invalid" ? "error" : "primary"}
weight={props.readOnly ? "normal" : "bold"} weight={props.labelWeight || "bold"}
inverted={props.inverted} inverted={props.inverted}
> >
{props.label} {props.label}

View File

@@ -85,6 +85,14 @@ export const TextArea = (props: TextAreaProps) => {
"maxRows", "maxRows",
]); ]);
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return ( return (
<TextField <TextField
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
@@ -92,13 +100,20 @@ export const TextArea = (props: TextAreaProps) => {
// but not in webkit, so we capture the parent ref and query for the textarea // but not in webkit, so we capture the parent ref and query for the textarea
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement; textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}} }}
class={cx("form-field", "textarea", props.size, props.orientation, { class={cx(
inverted: props.inverted, styleProps.class,
ghost: props.ghost, "form-field",
})} "textarea",
{...props} styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
> >
<Orienter orientation={props.orientation} align={"start"}> <Orienter orientation={styleProps.orientation} align={"start"}>
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}

View File

@@ -11,6 +11,7 @@ import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { splitProps } from "solid-js";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
@@ -19,15 +20,30 @@ export type TextInputProps = FieldProps &
}; };
export const TextInput = (props: TextInputProps) => { export const TextInput = (props: TextInputProps) => {
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return ( return (
<TextField <TextField
class={cx("form-field", "text", props.size, props.orientation, { class={cx(
inverted: props.inverted, styleProps.class,
ghost: props.ghost, "form-field",
})} "text",
{...props} styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
> >
<Orienter orientation={props.orientation}> <Orienter orientation={styleProps.orientation}>
<Label <Label
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
@@ -37,7 +53,7 @@ export const TextInput = (props: TextInputProps) => {
{props.icon && !props.readOnly && ( {props.icon && !props.readOnly && (
<Icon <Icon
icon={props.icon} icon={props.icon}
inverted={props.inverted} inverted={styleProps.inverted}
color={props.disabled ? "tertiary" : "quaternary"} color={props.disabled ? "tertiary" : "quaternary"}
/> />
)} )}

View File

@@ -5,6 +5,7 @@ import {
createContext, createContext,
createSignal, createSignal,
useContext, useContext,
ParentComponent,
} from "solid-js"; } from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog"; import { Dialog as KDialog } from "@kobalte/core/dialog";
import styles from "./Modal.module.css"; import styles from "./Modal.module.css";
@@ -27,6 +28,10 @@ export const useModalContext = () => {
return context as ModalContextType; return context as ModalContextType;
}; };
const defaultContentWrapper: ParentComponent = (props): JSX.Element => (
<>{props.children}</>
);
export interface ModalProps { export interface ModalProps {
id?: string; id?: string;
title: string; title: string;
@@ -35,53 +40,70 @@ export interface ModalProps {
mount?: Node; mount?: Node;
class?: string; class?: string;
metaHeader?: Component; metaHeader?: Component;
wrapContent?: ParentComponent;
disablePadding?: boolean; disablePadding?: boolean;
open: boolean; open: boolean;
} }
export const Modal = (props: ModalProps) => { export const Modal = (props: ModalProps) => {
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>(); const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
// allows wrapping the dialog content in a component
// useful with forms where the submit button is in the header
const contentWrapper: Component = props.wrapContent || defaultContentWrapper;
return ( return (
<Show when={props.open}> <Show when={props.open}>
<KDialog id={props.id} open={props.open} modal={true}> <KDialog id={props.id} open={props.open} modal={true}>
<KDialog.Portal mount={props.mount}> <KDialog.Portal mount={props.mount}>
<div class={styles.backdrop} /> <div class={styles.backdrop} />
<div class={styles.contentWrapper}> <div class={styles.contentWrapper}>
<KDialog.Content class={cx(styles.modal_content, props.class)}> <KDialog.Content
<div class={styles.modal_header}> class={cx(styles.modal_content, props.class)}
<Typography onEscapeKeyDown={props.onClose}
class={styles.modal_title} >
hierarchy="label" {contentWrapper({
family="mono" children: (
size="xs"
>
{props.title}
</Typography>
<Show when={props.onClose}>
<KDialog.CloseButton onClick={props.onClose}>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</Show>
</div>
<Show when={props.metaHeader}>
{(metaHeader) => (
<> <>
<div class="flex h-9 items-center px-6 py-2 bg-def-1"> <div class={styles.modal_header}>
<Dynamic component={metaHeader()} /> <Typography
class={styles.modal_title}
hierarchy="label"
family="mono"
size="xs"
>
{props.title}
</Typography>
<Show when={props.onClose}>
<KDialog.CloseButton onClick={props.onClose}>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</Show>
</div>
<Show when={props.metaHeader}>
{(metaHeader) => (
<>
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
<Dynamic component={metaHeader()} />
</div>
<div class={styles.header_divider} />
</>
)}
</Show>
<div
class={styles.modal_body}
data-no-padding={props.disablePadding}
ref={setPortalRef}
>
<ModalContext.Provider
value={{ portalRef: portalRef()! }}
>
{props.children}
</ModalContext.Provider>
</div> </div>
<div class={styles.header_divider} />
</> </>
)} ),
</Show> })}
<div
class={styles.modal_body}
data-no-padding={props.disablePadding}
ref={setPortalRef}
>
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
{props.children}
</ModalContext.Provider>
</div>
</KDialog.Content> </KDialog.Content>
</div> </div>
</KDialog.Portal> </KDialog.Portal>

View File

@@ -3,20 +3,18 @@ import Icon from "@/src/components/Icon/Icon";
import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import { createSignal, For, Suspense, useContext } from "solid-js"; import { createSignal, For, Show, Suspense, useContext } from "solid-js";
import { import { navigateToOnboarding } from "@/src/hooks/clan";
navigateToClan,
navigateToOnboarding,
useClanURI,
} from "@/src/hooks/clan";
import { setActiveClanURI } from "@/src/stores/clan"; import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan"; import { ClanContext } from "@/src/routes/Clan/Clan";
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
export const SidebarHeader = () => { export const SidebarHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
const [showSettings, setShowSettings] = createSignal(false);
// get information about the current active clan // get information about the current active clan
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
@@ -25,20 +23,27 @@ export const SidebarHeader = () => {
throw new Error("SidebarContext not found"); throw new Error("SidebarContext not found");
} }
const clanURI = useClanURI();
const clanChar = () => const clanChar = () =>
ctx?.activeClanQuery?.data?.name.charAt(0).toUpperCase(); ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
const clanName = () => ctx?.activeClanQuery?.data?.name; const clanName = () => ctx?.activeClanQuery?.data?.details.name;
const clanList = () => const clanList = () =>
ctx.allClansQueries ctx.allClansQueries
.filter((it) => it.isSuccess) .filter((it) => it.isSuccess)
.map((it) => it.data!) .map((it) => it.data!)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.details.name.localeCompare(b.details.name));
return ( return (
<div class="sidebar-header"> <div class="sidebar-header">
<Show when={ctx.activeClanQuery.isSuccess && showSettings()}>
<ClanSettingsModal
model={ctx.activeClanQuery.data!}
onClose={() => {
ctx?.activeClanQuery?.refetch(); // refresh clan data
setShowSettings(false);
}}
/>
</Show>
<Suspense fallback={"Loading..."}> <Suspense fallback={"Loading..."}>
<DropdownMenu open={open()} onOpenChange={setOpen} sameWidth={true}> <DropdownMenu open={open()} onOpenChange={setOpen} sameWidth={true}>
<DropdownMenu.Trigger class="dropdown-trigger"> <DropdownMenu.Trigger class="dropdown-trigger">
@@ -70,7 +75,7 @@ export const SidebarHeader = () => {
<DropdownMenu.Content class="sidebar-dropdown-content"> <DropdownMenu.Content class="sidebar-dropdown-content">
<DropdownMenu.Item <DropdownMenu.Item
class="dropdown-item" class="dropdown-item"
onSelect={() => navigateToClan(navigate, clanURI)} onSelect={() => setShowSettings(true)}
> >
<Icon <Icon
icon="Settings" icon="Settings"
@@ -118,7 +123,7 @@ export const SidebarHeader = () => {
size="xs" size="xs"
weight="medium" weight="medium"
> >
{clan.name} {clan.details.name}
</Typography> </Typography>
</DropdownMenu.Item> </DropdownMenu.Item>
</Suspense> </Suspense>

View File

@@ -10,8 +10,11 @@ import { useApiClient } from "./ApiClient";
import { experimental_createQueryPersister } from "@tanstack/solid-query-persist-client"; import { experimental_createQueryPersister } from "@tanstack/solid-query-persist-client";
import { ClanDetailsStore } from "@/src/stores/clanDetails"; import { ClanDetailsStore } from "@/src/stores/clanDetails";
export type ClanDetails = SuccessData<"get_clan_details">; export interface ClanDetails {
export type ClanDetailsWithURI = ClanDetails & { uri: string }; uri: string;
details: SuccessData<"get_clan_details">;
fieldsSchema: SuccessData<"get_clan_details_schema">;
}
export type Tags = SuccessData<"list_tags">; export type Tags = SuccessData<"list_tags">;
export type Machine = SuccessData<"get_machine">; export type Machine = SuccessData<"get_machine">;
@@ -29,7 +32,7 @@ export interface MachineDetail {
} }
export type MachinesQueryResult = UseQueryResult<ListMachines>; export type MachinesQueryResult = UseQueryResult<ListMachines>;
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[]; export type ClanListQueryResult = UseQueryResult<ClanDetails>[];
export const DefaultQueryClient = new QueryClient({ export const DefaultQueryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -65,7 +68,7 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
return useQuery<MachineDetail>(() => ({ return useQuery<MachineDetail>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => { queryFn: async () => {
const [tagsCall, machineCall, schemaCall] = await Promise.all([ const [tagsCall, machineCall, schemaCall] = [
client.fetch("list_tags", { client.fetch("list_tags", {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
@@ -85,7 +88,7 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
}, },
}, },
}), }),
]); ];
const tags = await tagsCall.result; const tags = await tagsCall.result;
if (tags.status === "error") { if (tags.status === "error") {
@@ -176,26 +179,45 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
export const useClanDetailsQuery = (clanURI: string) => { export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ClanDetailsWithURI>(() => ({ return useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: ["clans", encodeBase64(clanURI), "details"],
persister: ClanDetailsPersister.persisterFn, persister: ClanDetailsPersister.persisterFn,
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_clan_details", { const args = {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
}, },
}); };
const result = await call.result;
if (result.status === "error") { const [detailsCall, schemaCall] = [
// todo should we create some specific error types? client.fetch("get_clan_details", args),
console.error("Error fetching clan details", clanURI, result.errors); client.fetch("get_clan_details_schema", {
throw new Error(result.errors[0].message); flake: {
identifier: clanURI,
},
}),
];
const details = await detailsCall.result;
if (details.status === "error") {
throw new Error(
"Error fetching clan details: " + details.errors[0].message,
);
}
const schema = await schemaCall.result;
if (schema.status === "error") {
throw new Error(
"Error fetching clan details schema: " + schema.errors[0].message,
);
} }
return { return {
uri: clanURI, uri: clanURI,
...result.data, details: details.data!,
fieldsSchema: schema.data,
}; };
}, },
})); }));
@@ -230,21 +252,41 @@ export const useClanListQuery = (
} }
} }
const call = client.fetch("get_clan_details", { const args = {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
}, },
}); };
const result = await call.result;
if (result.status === "error") { const [detailsCall, schemaCall] = [
// todo should we create some specific error types? client.fetch("get_clan_details", args),
throw new Error(result.errors[0].message); client.fetch("get_clan_details_schema", {
flake: {
identifier: clanURI,
},
}),
];
const details = await detailsCall.result;
if (details.status === "error") {
throw new Error(
"Error fetching clan details: " + details.errors[0].message,
);
}
const schema = await schemaCall.result;
if (schema.status === "error") {
throw new Error(
"Error fetching clan details schema: " + schema.errors[0].message,
);
} }
return { return {
uri: clanURI, uri: clanURI,
...result.data, details: details.data,
fieldsSchema: schema.data,
}; };
}, },
}; };

View File

@@ -0,0 +1,24 @@
.modal {
@apply w-screen max-w-xl h-fit flex flex-col;
.content {
@apply flex flex-col gap-3 size-full;
}
.header {
@apply flex w-full items-center justify-between;
}
.remove {
@apply flex justify-between items-center px-4 py-5 gap-8;
@apply rounded-md bg-semantic-error-2 border-[0.0625rem] border-semantic-error-4;
.clanInput {
@apply grow;
}
.buttons {
@apply flex items-center gap-2;
}
}
}

View File

@@ -0,0 +1,38 @@
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { ClanSettingsModal, ClanSettingsModalProps } from "./ClanSettingsModal";
const meta: Meta<ClanSettingsModalProps> = {
title: "Modals/ClanSettings",
component: ClanSettingsModal,
};
export default meta;
type Story = StoryObj<ClanSettingsModalProps>;
export const Default: Story = {
args: {
onClose: fn(),
model: {
uri: "/home/foo/my-clan",
name: "Sol",
description: null,
icon: null,
fieldsSchema: {
name: {
readonly: true,
reason: null,
},
description: {
readonly: false,
reason: null,
},
icon: {
readonly: false,
reason: null,
},
},
},
},
};

View File

@@ -0,0 +1,208 @@
import cx from "classnames";
import styles from "./ClanSettingsModal.module.css";
import { Modal } from "@/src/components/Modal/Modal";
import { ClanDetails } from "@/src/hooks/queries";
import * as v from "valibot";
import {
createForm,
getErrors,
Maybe,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { TextInput } from "@/src/components/Form/TextInput";
import { tooltipText } from "@/src/components/Form";
import { TextArea } from "@/src/components/Form/TextArea";
import { createSignal, Show, splitProps } from "solid-js";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { Divider } from "@/src/components/Divider/Divider";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { callApi } from "@/src/hooks/api";
import { Alert } from "@/src/components/Alert/Alert";
import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({
name: v.pipe(v.optional(v.string())),
description: v.nullish(v.string()),
icon: v.pipe(v.nullish(v.string())),
});
export interface ClanSettingsModalProps {
model: ClanDetails;
onClose: () => void;
}
type FieldNames = "name" | "description" | "icon";
type FormValues = Pick<ClanDetails["details"], "name" | "description" | "icon">;
export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
const [saving, setSaving] = createSignal(false);
const [formStore, { Form, Field }] = createForm<FormValues>({
initialValues: props.model.details,
validate: valiForm<FormValues>(schema),
});
const readOnly = (name: FieldNames) =>
props.model.fieldsSchema[name]?.readonly ?? false;
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
if (!formStore.dirty) {
// nothing to save, just close the modal
props.onClose();
return;
}
// we only save stuff when the form is dirty
setSaving(true);
const call = callApi("set_clan_details", {
options: {
flake: {
identifier: props.model.uri,
},
meta: {
// todo we don't support icon field yet, so we mixin the original fields to stop the API from complaining
// about deleting a field
...props.model.details,
...values,
},
},
});
const result = await call.result;
setSaving(false);
if (result.status == "error") {
throw new Error(`Failed to save changes: ${result.errors[0].message}`);
}
if (result.status == "success") {
props.onClose();
}
};
const errorMessage = (): Maybe<string> => {
const formErrors = getErrors(formStore);
const firstFormError = Object.values(formErrors).find(
(value) => value,
) as Maybe<string>;
return firstFormError || formStore.response.message;
};
const [removeValue, setRemoveValue] = createSignal("");
const removeDisabled = () => removeValue() !== props.model.details.name;
const onRemove = () => {
removeClanURI(props.model.uri);
};
return (
<Modal
class={cx(styles.modal)}
open
title="Settings"
onClose={props.onClose}
wrapContent={(props) => (
<Form onSubmit={handleSubmit}>{props.children}</Form>
)}
metaHeader={() => (
<div class={cx(styles.header)}>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="medium"
>
{props.model.details.name}
</Typography>
<Button hierarchy="primary" size="s" type="submit" loading={saving()}>
Save
</Button>
</div>
)}
>
<div class={cx(styles.content)}>
<Show when={errorMessage()}>
<Alert type="error" title="Error" description={errorMessage()} />
</Show>
<Fieldset>
<Field name="name">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Name"
required
readOnly={readOnly("name")}
orientation="horizontal"
input={input}
tooltip={tooltipText(
"name",
props.model.fieldsSchema,
"A unique identifier for this Clan",
)}
/>
)}
</Field>
<Divider />
<Field name="description">
{(field, input) => (
<TextArea
{...splitProps(field, ["value"])[1]}
defaultValue={field.value ?? ""}
label="Description"
readOnly={readOnly("description")}
tooltip={tooltipText(
"description",
props.model.fieldsSchema,
"A description of this Clan",
)}
orientation="horizontal"
input={{
...input,
autoResize: true,
minRows: 2,
maxRows: 4,
placeholder: "No description",
}}
/>
)}
</Field>
</Fieldset>
<div class={cx(styles.remove)}>
<TextInput
class={cx(styles.clanInput)}
orientation="horizontal"
onChange={setRemoveValue}
input={{
value: removeValue(),
placeholder: "Enter the name of this Clan",
onKeyDown: (event) => {
if (event.key == "Enter" && !removeDisabled()) {
onRemove();
}
},
}}
/>
<Button
hierarchy="primary"
size="s"
startIcon="Trash"
disabled={removeDisabled()}
onClick={onRemove}
>
Remove
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -82,8 +82,8 @@ export const ListClansModal = (props: ListClansModalProps) => {
{(clan) => ( {(clan) => (
<li> <li>
<NavSection <NavSection
label={clan.data.name} label={clan.data.details.name}
description={clan.data.description ?? undefined} description={clan.data.details.description ?? undefined}
onClick={selectClan(clan.data.uri)} onClick={selectClan(clan.data.uri)}
/> />
</li> </li>

View File

@@ -17,7 +17,7 @@ import {
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes"; import { CubeScene } from "@/src/scene/cubes";
import { import {
ClanDetailsWithURI, ClanDetails,
MachinesQueryResult, MachinesQueryResult,
useClanDetailsQuery, useClanDetailsQuery,
useClanListQuery, useClanListQuery,
@@ -40,9 +40,9 @@ import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
interface ClanContextProps { interface ClanContextProps {
clanURI: string; clanURI: string;
machinesQuery: MachinesQueryResult; machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetailsWithURI>; activeClanQuery: UseQueryResult<ClanDetails>;
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[]; otherClanQueries: UseQueryResult<ClanDetails>[];
allClansQueries: UseQueryResult<ClanDetailsWithURI>[]; allClansQueries: UseQueryResult<ClanDetails>[];
isLoading(): boolean; isLoading(): boolean;
isError(): boolean; isError(): boolean;
@@ -51,9 +51,9 @@ interface ClanContextProps {
class DefaultClanContext implements ClanContextProps { class DefaultClanContext implements ClanContextProps {
public readonly clanURI: string; public readonly clanURI: string;
public readonly activeClanQuery: UseQueryResult<ClanDetailsWithURI>; public readonly activeClanQuery: UseQueryResult<ClanDetails>;
public readonly otherClanQueries: UseQueryResult<ClanDetailsWithURI>[]; public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
public readonly allClansQueries: UseQueryResult<ClanDetailsWithURI>[]; public readonly allClansQueries: UseQueryResult<ClanDetails>[];
public readonly machinesQuery: MachinesQueryResult; public readonly machinesQuery: MachinesQueryResult;
@@ -62,8 +62,8 @@ class DefaultClanContext implements ClanContextProps {
constructor( constructor(
clanURI: string, clanURI: string,
machinesQuery: MachinesQueryResult, machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetailsWithURI>, activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[], otherClanQueries: UseQueryResult<ClanDetails>[],
) { ) {
this.clanURI = clanURI; this.clanURI = clanURI;
this.machinesQuery = machinesQuery; this.machinesQuery = machinesQuery;

View File

@@ -78,6 +78,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
size="s" size="s"
inverted inverted
label="Name" label="Name"
labelWeight="normal"
required required
readOnly={readOnly(editing, "name")} readOnly={readOnly(editing, "name")}
orientation="horizontal" orientation="horizontal"
@@ -99,6 +100,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
size="s" size="s"
inverted inverted
label="Platform" label="Platform"
labelWeight="normal"
required required
readOnly={readOnly(editing, "machineClass")} readOnly={readOnly(editing, "machineClass")}
orientation="horizontal" orientation="horizontal"
@@ -119,6 +121,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
defaultValue={field.value ?? ""} defaultValue={field.value ?? ""}
size="s" size="s"
label="Description" label="Description"
labelWeight="normal"
inverted inverted
readOnly={readOnly(editing, "description")} readOnly={readOnly(editing, "description")}
tooltip={tooltipText("description", fieldsSchema()!)} tooltip={tooltipText("description", fieldsSchema()!)}

View File

@@ -3,8 +3,10 @@ import logging
from clan_lib.api import API from clan_lib.api import API
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.flake import Flake from clan_lib.flake import Flake
from clan_lib.machines.actions import FieldSchema
from clan_lib.nix_models.clan import InventoryMeta from clan_lib.nix_models.clan import InventoryMeta
from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import is_writeable_key, retrieve_typed_field_names
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -32,3 +34,33 @@ def get_clan_details(flake: Flake) -> InventoryMeta:
raise ClanError(msg) raise ClanError(msg)
return meta return meta
@API.register
def get_clan_details_schema(flake: Flake) -> dict[str, FieldSchema]:
"""
Get attributes for each field of the clan.
This function checks which fields of the 'clan' resource are readonly and provides a reason if so.
Args:
flake (Flake): The Flake object for which to retrieve fields.
Returns:
dict[str, FieldSchema]: A map from field-names to { 'readonly' (bool) and 'reason' (str or None ) }
"""
inventory_store = InventoryStore(flake)
write_info = inventory_store.get_writeability()
field_names = retrieve_typed_field_names(InventoryMeta)
return {
field: {
"readonly": not is_writeable_key(f"meta.{field}", write_info),
# TODO: Provide a meaningful reason
"reason": None,
"readonly_members": [],
}
for field in field_names
}