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:
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()!)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user