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
|
||||
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}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface FieldProps {
|
||||
class?: string;
|
||||
label?: string;
|
||||
labelWeight?: "bold" | "normal";
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
ghost?: boolean;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,53 +40,70 @@ 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)}>
|
||||
<div class={styles.modal_header}>
|
||||
<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) => (
|
||||
<KDialog.Content
|
||||
class={cx(styles.modal_content, props.class)}
|
||||
onEscapeKeyDown={props.onClose}
|
||||
>
|
||||
{contentWrapper({
|
||||
children: (
|
||||
<>
|
||||
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
|
||||
<Dynamic component={metaHeader()} />
|
||||
<div class={styles.modal_header}>
|
||||
<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 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>
|
||||
</div>
|
||||
</KDialog.Portal>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()!)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user