feat(ui): support editing basic metadata for a Clan

This commit is contained in:
Brian McGee
2025-08-22 15:58:14 +01:00
parent f185d28f68
commit 431e45cc3a
9 changed files with 388 additions and 76 deletions

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,12 +40,18 @@ 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}>
@@ -48,40 +59,48 @@ export const Modal = (props: ModalProps) => {
<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) => (
{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>

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,7 @@
.modal {
@apply w-screen max-w-xl h-fit flex flex-col;
.header {
@apply flex w-full items-center justify-between;
}
}

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,170 @@
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";
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;
};
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>
)}
>
<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>
</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;