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
const [_, rootProps] = splitProps(props, ["input"]);
const [styleProps, otherRootProps] = splitProps(rootProps, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
const alignment = () =>
(props.orientation || "vertical") == "vertical" ? "start" : "center";
@@ -47,14 +55,21 @@ export const Checkbox = (props: CheckboxProps) => {
return (
<KCheckbox.Root
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...rootProps}
class={cx(
styleProps.class,
"form-field",
"checkbox",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherRootProps}
>
{(state) => (
<Orienter orientation={props.orientation} align={alignment()}>
<Orienter orientation={styleProps.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,14 @@ export const TextArea = (props: TextAreaProps) => {
"maxRows",
]);
const [styleProps, otherProps] = splitProps(props, [
"class",
"size",
"orientation",
"inverted",
"ghost",
]);
return (
<TextField
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
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}}
class={cx("form-field", "textarea", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
class={cx(
styleProps.class,
"form-field",
"textarea",
styleProps.size,
styleProps.orientation,
{
inverted: styleProps.inverted,
ghost: styleProps.ghost,
},
)}
{...otherProps}
>
<Orienter orientation={props.orientation} align={"start"}>
<Orienter orientation={styleProps.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}

View File

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

View File

@@ -5,6 +5,7 @@ import {
createContext,
createSignal,
useContext,
ParentComponent,
} from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog";
import styles from "./Modal.module.css";
@@ -27,6 +28,10 @@ export const useModalContext = () => {
return context as ModalContextType;
};
const defaultContentWrapper: ParentComponent = (props): JSX.Element => (
<>{props.children}</>
);
export interface ModalProps {
id?: string;
title: string;
@@ -35,19 +40,31 @@ export interface ModalProps {
mount?: Node;
class?: string;
metaHeader?: Component;
wrapContent?: ParentComponent;
disablePadding?: boolean;
open: boolean;
}
export const Modal = (props: ModalProps) => {
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 (
<Show when={props.open}>
<KDialog id={props.id} open={props.open} modal={true}>
<KDialog.Portal mount={props.mount}>
<div class={styles.backdrop} />
<div class={styles.contentWrapper}>
<KDialog.Content class={cx(styles.modal_content, props.class)}>
<KDialog.Content
class={cx(styles.modal_content, props.class)}
onEscapeKeyDown={props.onClose}
>
{contentWrapper({
children: (
<>
<div class={styles.modal_header}>
<Typography
class={styles.modal_title}
@@ -78,10 +95,15 @@ export const Modal = (props: ModalProps) => {
data-no-padding={props.disablePadding}
ref={setPortalRef}
>
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
<ModalContext.Provider
value={{ portalRef: portalRef()! }}
>
{props.children}
</ModalContext.Provider>
</div>
</>
),
})}
</KDialog.Content>
</div>
</KDialog.Portal>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,10 @@ import logging
from clan_lib.api import API
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.machines.actions import FieldSchema
from clan_lib.nix_models.clan import InventoryMeta
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__)
@@ -32,3 +34,33 @@ def get_clan_details(flake: Flake) -> InventoryMeta:
raise ClanError(msg)
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
}