public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { type Static, StringEnum, StringIdentifier, Type, transformObject } from "core/utils";
import { FieldClassMap } from "data";
import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, useState } from "react";
import {
Modal2,
type Modal2Ref,
ModalBody,
ModalFooter,
ModalTitle
} from "ui/components/modal/Modal2";
import { Step, Steps, useStepContext } from "ui/components/steps/Steps";
import { StepCreate } from "ui/modules/data/components/schema/create-modal/step.create";
import { StepEntity } from "./step.entity";
import { StepEntityFields } from "./step.entity.fields";
import { StepRelation } from "./step.relation";
import { StepSelect } from "./step.select";
import Templates from "./templates/register";
export type CreateModalRef = Modal2Ref;
export const ModalActions = ["entity", "relation", "media"] as const;
export const entitySchema = Type.Composite([
Type.Object({
name: StringIdentifier
}),
entitiesSchema
]);
const schemaAction = Type.Union([
StringEnum(["entity", "relation", "media"]),
Type.String({ pattern: "^template-" })
]);
export type TSchemaAction = Static<typeof schemaAction>;
const createFieldSchema = Type.Object({
entity: StringIdentifier,
name: StringIdentifier,
field: Type.Array(fieldsSchema)
});
export type TFieldCreate = Static<typeof createFieldSchema>;
const createModalSchema = Type.Object(
{
action: schemaAction,
entities: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(entitySchema))
})
),
relations: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(Type.Union(relationsSchema)))
})
),
fields: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(createFieldSchema))
})
)
},
{
additionalProperties: false
}
);
export type TCreateModalSchema = Static<typeof createModalSchema>;
export const CreateModal = forwardRef<CreateModalRef>(function CreateModal(props, ref) {
const [path, setPath] = useState<string[]>([]);
function close() {
// @ts-ignore
ref?.current?.close();
}
return (
<Modal2 ref={ref}>
<Steps path={path} lastBack={close}>
<Step id="select">
<ModalTitle path={["Create New"]} onClose={close} />
<StepSelect />
</Step>
<Step id="entity" path={["action"]}>
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
<StepEntity />
</Step>
<Step id="entity-fields" path={["action", "entity"]}>
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
<StepEntityFields />
</Step>
<Step id="relation" path={["action"]}>
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
<StepRelation />
</Step>
<Step id="create" path={["action"]}>
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
<StepCreate />
</Step>
{/* Templates */}
{Templates.map(([Component, meta]) => (
<Step key={meta.id} id={meta.id} path={["action"]}>
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
<Component />
</Step>
))}
</Steps>
</Modal2>
);
});
export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema };

View File

@@ -0,0 +1,187 @@
import { useDisclosure } from "@mantine/hooks";
import {
IconAlignJustified,
IconAugmentedReality,
IconBox,
IconCirclesRelation,
IconInfoCircle
} from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { useEffect, useState } from "react";
import { TbCirclesRelation, TbSettings } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { IconButton, type IconType } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { ModalBody, ModalFooter } from "ui/components/modal/Modal2";
import { useStepContext } from "ui/components/steps/Steps";
import type { TCreateModalSchema } from "ui/modules/data/components/schema/create-modal/CreateModal";
type ActionItem = SummaryItemProps & {
run: () => Promise<boolean>;
};
export function StepCreate() {
const { stepBack, state, close } = useStepContext<TCreateModalSchema>();
const [states, setStates] = useState<(boolean | string)[]>([]);
const [submitting, setSubmitting] = useState(false);
const $data = useBkndData();
const items: ActionItem[] = [];
if (state.entities?.create) {
items.push(
...state.entities.create.map((entity) => ({
action: "add",
Icon: IconBox,
type: "Entity",
name: entity.name,
json: entity,
run: async () => await $data.actions.entity.add(entity.name, entity)
}))
);
}
if (state.fields?.create) {
items.push(
...state.fields.create.map((field) => ({
action: "add",
Icon: IconAlignJustified,
type: "Field",
name: field.name,
json: field,
run: async () =>
await $data.actions.entity
.patch(field.entity)
.fields.add(field.name, field.field as any)
}))
);
}
if (state.relations?.create) {
items.push(
...state.relations.create.map((rel) => ({
action: "add",
Icon: IconCirclesRelation,
type: "Relation",
name: `${rel.source} -> ${rel.target} (${rel.type})`,
json: rel,
run: async () => await $data.actions.relations.add(rel)
}))
);
}
async function handleCreate() {
setSubmitting(true);
for (const item of items) {
try {
const res = await item.run();
setStates((prev) => [...prev, res]);
} catch (e) {
setStates((prev) => [...prev, (e as any).message]);
}
}
}
useEffect(() => {
console.log(
"states",
states,
items,
states.length,
items.length,
states.every((s) => s === true)
);
if (items.length === states.length && states.every((s) => s === true)) {
close();
} else {
setSubmitting(false);
}
}, [states]);
return (
<>
<ModalBody>
<div>This is what will be created. Please confirm by clicking "Next".</div>
<div className="flex flex-col gap-1">
{items.map((item, i) => (
<SummaryItem key={i} {...item} state={states[i]} />
))}
</div>
{/*<div>{submitting ? "submitting" : "idle"}</div>
<div>
{states.length}/{items.length}
</div>*/}
</ModalBody>
<ModalFooter
nextLabel="Create"
next={{
onClick: handleCreate,
disabled: submitting
}}
prev={{ onClick: stepBack, disabled: submitting }}
debug={{ state }}
/>
</>
);
}
type SummaryItemProps = {
Icon: IconType;
action: "add" | string;
type: string;
name: string;
json?: object;
state?: boolean | string;
initialExpanded?: boolean;
};
const SummaryItem: React.FC<SummaryItemProps> = ({
Icon,
type,
name,
json,
state,
action,
initialExpanded = false
}) => {
const [expanded, handlers] = useDisclosure(initialExpanded);
const error = typeof state !== "undefined" && state !== true;
return (
<div
className={twMerge(
"flex flex-col border border-muted rounded bg-background mb-2",
error && "bg-red-500/20"
)}
>
<div className="flex flex-row gap-4 px-2 py-2 items-center">
<div className="flex flex-row items-center p-1 bg-primary/5 rounded">
<Icon className="w-6 h-6" />
</div>
<div className="flex flex-row flex-grow gap-5">
<Desc type="action" name={action} />
<Desc type="type" name={type} />
<Desc type="name" name={name} />
</div>
{json && (
<IconButton
Icon={IconInfoCircle}
variant={expanded ? "default" : "ghost"}
onClick={handlers.toggle}
/>
)}
</div>
{json && expanded && (
<div className="flex flex-col border-t border-t-muted">
<JsonViewer json={json} expand={8} className="text-sm" />
</div>
)}
{error && typeof state === "string" && <div className="text-sm text-red-500">{state}</div>}
</div>
);
};
const Desc = ({ type, name }) => (
<div className="flex flex-row text-sm font-mono gap-2">
<div className="opacity-50">{ucFirst(type)}</div>
<div className="font-semibold">{name}</div>
</div>
);

View File

@@ -0,0 +1,122 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useEvent } from "ui/hooks/use-event";
import {
EntityFieldsForm,
type EntityFieldsFormRef
} from "ui/routes/data/forms/entity.fields.form";
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
const schema = entitiesSchema;
export function StepEntityFields() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const entity = state.entities?.create?.[0]!;
const defaultFields = { id: { type: "primary", name: "id" } } as const;
const ref = useRef<EntityFieldsFormRef>(null);
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue
} = useForm({
mode: "onTouched",
resolver: typeboxResolver(schema),
defaultValues: {
...entity,
fields: defaultFields,
config: {
sort_field: "id",
sort_dir: "asc"
}
}
});
const values = watch();
const updateListener = useEvent((data: TAppDataEntityFields) => {
console.log("updateListener", data);
setValue("fields", data as any);
});
function handleNext() {
if (isValid && ref.current?.isValid()) {
setState((prev) => {
const entity = prev.entities?.create?.[0];
if (!entity) return prev;
return {
...prev,
entities: {
create: [getValues() as any]
}
};
});
nextStep("create")();
} else {
console.warn("not valid");
}
}
return (
<form onSubmit={handleSubmit(handleNext)}>
<ModalBody>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<p>
Add fields to <strong>{entity.name}</strong>:
</p>
<div className="flex flex-col gap-1">
<EntityFieldsForm ref={ref} fields={defaultFields} onChange={updateListener} />
</div>
</div>
<div className="flex flex-col gap-3">
<p>How should it be sorted by default?</p>
<div className="flex flex-row gap-2">
<MantineSelect
label="Field"
data={Object.keys(values.fields).filter((name) => name.length > 0)}
placeholder="Select field"
name="config.sort_field"
allowDeselect={false}
control={control}
/>
<MantineSelect
label="Direction"
data={["asc", "desc"]}
defaultValue="asc"
placeholder="Select direction"
name="config.sort_dir"
allowDeselect={false}
control={control}
/>
</div>
</div>
<div>
{Object.entries(errors).map(([key, value]) => (
<p key={key}>
{key}: {value.message}
</p>
))}
</div>
</div>
</ModalBody>
<ModalFooter
next={{
disabled: !isValid,
type: "submit"
//onClick: handleNext
}}
prev={{ onClick: stepBack }}
debug={{ state, values }}
/>
</form>
);
}

View File

@@ -0,0 +1,95 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput, Textarea } from "@mantine/core";
import { useFocusTrap } from "@mantine/hooks";
import { useForm } from "react-hook-form";
import {
ModalBody,
ModalFooter,
type TCreateModalSchema,
entitySchema,
useStepContext
} from "./CreateModal";
export function StepEntity() {
const focusTrapRef = useFocusTrap();
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { register, handleSubmit, formState, watch } = useForm({
mode: "onTouched",
resolver: typeboxResolver(entitySchema),
defaultValues: state.entities?.create?.[0] ?? {}
});
/*const data = watch();
console.log("state", { isValid });
console.log("schema", JSON.stringify(entitySchema));
console.log("data", JSON.stringify(data));*/
function onSubmit(data: any) {
console.log(data);
setState((prev) => {
const prevEntity = prev.entities?.create?.[0];
if (prevEntity && prevEntity.name !== data.name) {
return { ...prev, entities: { create: [{ ...data, fields: prevEntity.fields }] } };
}
return { ...prev, entities: { create: [data] } };
});
if (formState.isValid) {
console.log("would go next");
nextStep("entity-fields")();
}
}
return (
<>
<form onSubmit={handleSubmit(onSubmit)} ref={focusTrapRef}>
<ModalBody>
<TextInput
data-autofocus
required
error={formState.errors.name?.message}
{...register("name")}
placeholder="posts"
size="md"
label="What's the name of the entity?"
description="Use plural form, and all lowercase. It will be used as the database table."
/>
{/*<input type="submit" value="submit" />*/}
<TextInput
{...register("config.name")}
error={formState.errors.config?.name?.message}
placeholder="Posts"
size="md"
label="How should it be called?"
description="Use plural form. This will be used to display in the UI."
/>
<TextInput
{...register("config.name_singular")}
error={formState.errors.config?.name_singular?.message}
placeholder="Post"
size="md"
label="What's the singular form of it?"
/>
<Textarea
placeholder="This is a post (optional)"
error={formState.errors.config?.description?.message}
{...register("config.description")}
size="md"
label={"Description"}
/>
</ModalBody>
<ModalFooter
next={{
type: "submit",
disabled: !formState.isValid
//onClick:
}}
prev={{ onClick: stepBack }}
debug={{ state }}
/>
</form>
</>
);
}

View File

@@ -0,0 +1,377 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Select, Switch, TextInput } from "@mantine/core";
import { TypeRegistry } from "@sinclair/typebox";
import {
type Static,
StringEnum,
StringIdentifier,
Type,
registerCustomTypeboxKinds
} from "core/utils";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
import { type ReactNode, useEffect } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { useBknd } from "ui/client";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useStepContext } from "ui/components/steps/Steps";
import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal";
// @todo: check if this could become an issue
registerCustomTypeboxKinds(TypeRegistry);
const Relations: {
type: RelationType;
label: string;
component: (props: ComponentCtx<any>) => ReactNode;
}[] = [
{
type: RelationTypes.ManyToOne,
label: "Many to One",
component: ManyToOne
},
{
type: RelationTypes.OneToOne,
label: "One to One",
component: OneToOne
},
{
type: RelationTypes.ManyToMany,
label: "Many to Many",
component: ManyToMany
},
{
type: RelationTypes.Polymorphic,
label: "Polymorphic",
component: Polymorphic
}
];
const schema = Type.Object({
type: StringEnum(Relations.map((r) => r.type)),
source: StringIdentifier,
target: StringIdentifier,
config: Type.Object({})
});
type ComponentCtx<T extends FieldValues = FieldValues> = {
register: UseFormRegister<T>;
control: Control<T>;
data: T;
};
export function StepRelation() {
const { config } = useBknd();
const entities = config.data.entities;
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const {
register,
handleSubmit,
formState: { isValid },
setValue,
watch,
control
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>
});
const data = watch();
console.log("data", { data, schema });
function handleNext() {
if (isValid) {
setState((prev) => {
return {
...prev,
relations: {
create: [data]
}
};
});
console.log("data", data);
nextStep("create")();
}
}
return (
<>
<form onSubmit={handleSubmit(handleNext)}>
<ModalBody>
<div className="grid grid-cols-3 gap-8">
<MantineSelect
control={control}
name="source"
label="Source"
allowDeselect={false}
data={Object.entries(entities ?? {}).map(([name, entity]) => ({
value: name,
label: entity.config?.name ?? name,
disabled: data.target === name
}))}
/>
<MantineSelect
control={control}
name="type"
onChange={() => setValue("config", {})}
label="Relation Type"
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
allowDeselect={false}
/>
<MantineSelect
control={control}
allowDeselect={false}
name="target"
label="Target"
data={Object.entries(entities ?? {}).map(([name, entity]) => ({
value: name,
label: entity.config?.name ?? name,
disabled: data.source === name
}))}
/>
</div>
<div>
{data.type &&
Relations.find((r) => r.type === data.type)?.component({
register,
control,
data
})}
</div>
</ModalBody>
<ModalFooter
next={{
type: "submit",
disabled: !isValid,
onClick: handleNext
}}
prev={{ onClick: stepBack }}
debug={{ state, data }}
/>
</form>
</>
);
}
const Pre = ({ children }: { children: ReactNode }) => (
<b className="font-mono select-text">{children}</b>
);
const Callout = ({ children }: { children: ReactNode }) => (
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
);
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
return (
<>
<div className="grid grid-cols-3 gap-8">
<div className="flex flex-col gap-4">
<TextInput
label="Target mapping"
{...register("config.mappedBy")}
placeholder={target}
/>
<MantineNumberInput
label="Cardinality"
name="config.sourceCardinality"
control={control}
placeholder="n"
/>
<MantineNumberInput
label="WITH limit"
name="config.with_limit"
control={control}
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
/>
</div>
<div />
<div className="flex flex-col gap-4">
<TextInput
label="Source mapping"
{...register("config.inversedBy")}
placeholder={source}
/>
<Switch label="Required" {...register("config.required")} />
</div>
</div>
{source && target && config && (
<Callout>
<>
<p>
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>.
</p>
{config.sourceCardinality ? (
<p>
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.sourceCardinality}</Pre> referencing entr
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p>
) : null}
</>
</Callout>
)}
</>
);
}
function OneToOne({
register,
control,
data: {
source,
target,
config: { mappedBy, required }
}
}: ComponentCtx) {
return (
<>
<div className="grid grid-cols-3 gap-8">
<div className="flex flex-col gap-4">
<TextInput
label="Target mapping"
{...register("config.mappedBy")}
placeholder={target}
/>
<Switch label="Required" {...register("config.required")} />
</div>
<div />
<div className="flex flex-col gap-4">
<TextInput
label="Source mapping"
{...register("config.inversedBy")}
placeholder={source}
/>
</div>
</div>
{source && target && (
<Callout>
<>
<p>
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
<Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>.
</p>
</>
</Callout>
)}
</>
);
}
function ManyToMany({ register, control, data: { source, target, config } }: ComponentCtx) {
const table = config.connectionTable
? config.connectionTable
: source && target
? `${source}_${target}`
: "";
return (
<>
<div className="grid grid-cols-3 gap-8">
<div className="flex flex-col gap-4">
{/*<TextInput
label="Target mapping"
{...register("config.mappedBy")}
placeholder={target}
/>*/}
</div>
<div className="flex flex-col gap-4">
<TextInput
label="Connection Table"
{...register("config.connectionTable")}
placeholder={table}
/>
<TextInput
label="Connection Mapping"
{...register("config.connectionTableMappedName")}
placeholder={table}
/>
</div>
<div className="flex flex-col gap-4">
{/*<TextInput
label="Source mapping"
{...register("config.inversedBy")}
placeholder={source}
/>*/}
</div>
</div>
{source && target && (
<Callout>
<>
<p>
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
A connection table <Pre>{table}</Pre> will be created to store the relations.
</p>
</>
</Callout>
)}
</>
);
}
function Polymorphic({ register, control, data: { type, source, target, config } }: ComponentCtx) {
return (
<>
<div className="grid grid-cols-3 gap-8" key={type}>
<div className="flex flex-col gap-4">
<TextInput
label="Target mapping"
{...register("config.mappedBy")}
placeholder={target}
/>
</div>
<div />
<div className="flex flex-col gap-4">
<TextInput
label="Source mapping"
{...register("config.inversedBy")}
placeholder={source}
/>
<MantineNumberInput
label="Cardinality"
name="config.targetCardinality"
control={control}
placeholder="n"
/>
</div>
</div>
{source && target && (
<Callout>
<>
<p>
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
<Pre>entity_id</Pre> to make the (polymorphic) reference.
</p>
{config.targetCardinality ? (
<p>
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.targetCardinality}</Pre> reference
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p>
) : null}
</>
</Callout>
)}
</>
);
}

View File

@@ -0,0 +1,116 @@
import type { IconType } from "react-icons";
import { TbBox, TbCirclesRelation, TbPhoto } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import {
type ModalActions,
ModalBody,
ModalFooter,
type TCreateModalSchema,
type TSchemaAction,
useStepContext
} from "./CreateModal";
import Templates from "./templates/register";
export function StepSelect() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const selected = state.action ?? null;
function handleSelect(action: TSchemaAction) {
if (selected === action) {
nextStep(action)();
return;
}
setState({ action });
}
return (
<>
<ModalBody>
<p>Choose what type to add.</p>
<div className="grid grid-cols-3 gap-3">
<RadioCard
Icon={TbBox}
title="Entity"
description="Create a new entity with fields"
onClick={() => handleSelect("entity")}
selected={selected === "entity"}
/>
<RadioCard
Icon={TbCirclesRelation}
title="Relation"
description="Create a new relation between entities"
onClick={() => handleSelect("relation")}
selected={selected === "relation"}
/>
{/*<RadioCard
Icon={TbPhoto}
title="Attach Media"
description="Attach media to an entity"
onClick={() => handleSelect("media")}
selected={selected === "media"}
/>*/}
</div>
<div className="flex flex-col gap-2 mt-3">
<h3 className="font-bold">Quick templates</h3>
<div className="grid grid-cols-2 gap-3">
{Templates.map(([, template]) => (
<RadioCard
key={template.id}
compact
Icon={TbPhoto}
title={template.title}
description={template.description}
onClick={() => handleSelect(template.id)}
selected={selected === template.id}
/>
))}
</div>
</div>
</ModalBody>
<ModalFooter
next={{
onClick: selected && nextStep(selected),
disabled: !selected
}}
prev={{ onClick: stepBack }}
prevLabel="Cancel"
/>
</>
);
}
const RadioCard = ({
Icon,
title,
description,
onClick,
selected,
compact = false,
disabled = false
}: {
Icon: IconType;
title: string;
description?: string;
selected?: boolean;
onClick?: () => void;
compact?: boolean;
disabled?: boolean;
}) => {
return (
<div
onClick={disabled !== true ? onClick : undefined}
className={twMerge(
"flex gap-3 border border-primary/10 rounded cursor-pointer",
compact ? "flex-row p-4 items-center" : "flex-col p-5",
selected ? "bg-primary/10 border-primary/50" : "hover:bg-primary/5",
disabled && "opacity-50"
)}
>
<Icon className="size-10" />
<div className="flex flex-col leading-tight">
<p className="text-lg font-bold">{title}</p>
<p>{description}</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { TemplateMediaComponent } from "./template.media.component";
export { TemplateMediaMeta } from "./template.media.meta";

View File

@@ -0,0 +1,183 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Radio, TextInput } from "@mantine/core";
import {
Default,
type Static,
StringEnum,
StringIdentifier,
Type,
transformObject
} from "core/utils";
import type { MediaFieldConfig } from "media/MediaField";
import { useForm } from "react-hook-form";
import { useBknd } from "ui/client";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineRadio } from "ui/components/form/hook-form-mantine/MantineRadio";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import {
ModalBody,
ModalFooter,
type TCreateModalSchema,
type TFieldCreate,
useStepContext
} from "../../CreateModal";
const schema = Type.Object({
entity: StringIdentifier,
cardinality_type: StringEnum(["single", "multiple"], { default: "multiple" }),
cardinality: Type.Optional(Type.Number({ minimum: 1 })),
name: StringIdentifier
});
type TCreateModalMediaSchema = Static<typeof schema>;
export function TemplateMediaComponent() {
const { stepBack, setState, state, nextStep } = useStepContext<TCreateModalSchema>();
const {
register,
handleSubmit,
formState: { isValid },
setValue,
watch,
control
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: Default(schema, {}) as TCreateModalMediaSchema
});
const { config } = useBknd();
const media_entity = config.media.entity_name ?? "media";
const entities = transformObject(config.data.entities ?? {}, (entity, name) =>
name !== media_entity ? entity : undefined
);
const data = watch();
async function handleCreate() {
if (isValid) {
console.log("data", data);
const { field, relation } = convert(media_entity, data);
console.log("state", { field, relation });
setState((prev) => ({
...prev,
fields: { create: [field] },
relations: { create: [relation] }
}));
nextStep("create")();
}
}
return (
<>
<form onSubmit={handleSubmit(handleCreate)}>
<ModalBody>
<div className="flex flex-col gap-6">
<MantineSelect
name="entity"
allowDeselect={false}
control={control}
size="md"
label="Choose which entity to add media to"
required
data={Object.entries(entities).map(([name, entity]) => ({
value: name,
label: entity.config?.name ?? name
}))}
/>
<MantineRadio.Group
name="cardinality_type"
control={control}
label="How many items can be attached?"
size="md"
>
<div className="flex flex-col gap-1">
<Radio label="Multiple items" value="multiple" />
<Radio label="Single item" value="single" />
</div>
</MantineRadio.Group>
{data.cardinality_type === "multiple" && (
<MantineNumberInput
name="cardinality"
control={control}
size="md"
label="How many exactly?"
placeholder="n"
description="Leave empty for unlimited"
inputWrapperOrder={["label", "input", "description", "error"]}
/>
)}
<TextInput
size="md"
label="Set a name for the property"
required
description={`A virtual property will be added to ${
data.entity ? data.entity : "the entity"
}.`}
{...register("name")}
/>
</div>
{/*<p>step template media</p>
<pre>{JSON.stringify(state, null, 2)}</pre>
<pre>{JSON.stringify(data, null, 2)}</pre>*/}
</ModalBody>
<ModalFooter
next={{
type: "submit",
disabled: !isValid
}}
prev={{
onClick: stepBack
}}
debug={{ state, data }}
/>
</form>
</>
);
}
function convert(media_entity: string, data: TCreateModalMediaSchema) {
const field: {
entity: string;
name: string;
field: { type: "media"; config: MediaFieldConfig };
} = {
name: data.name,
entity: data.entity,
field: {
type: "media" as any,
config: {
required: false,
fillable: ["update"],
hidden: false,
mime_types: [],
virtual: true,
entity: data.entity
}
}
};
const relation = {
type: "poly",
source: data.entity,
target: media_entity,
config: {
mappedBy: data.name,
targetCardinality: data.cardinality_type === "single" ? 1 : undefined
}
};
if (data.cardinality_type === "multiple") {
if (data.cardinality && data.cardinality > 1) {
field.field.config.max_items = data.cardinality;
relation.config.targetCardinality = data.cardinality;
}
} else {
field.field.config.max_items = 1;
relation.config.targetCardinality = 1;
}
// force fix types
const _field = field as unknown as TFieldCreate;
return { field: _field, relation };
}

View File

@@ -0,0 +1,9 @@
import { TbPhoto } from "react-icons/tb";
import type { StepTemplate } from "../register";
export const TemplateMediaMeta = {
id: "template-media",
title: "Attach Media",
description: "Attach media to an entity",
Icon: TbPhoto
} satisfies StepTemplate;

View File

@@ -0,0 +1,15 @@
import type { IconType } from "react-icons";
import { TemplateMediaComponent, TemplateMediaMeta } from "./media";
export type StepTemplate = {
id: string;
title: string;
description: string;
Icon: IconType;
};
const Templates: [() => JSX.Element, StepTemplate][] = [
[TemplateMediaComponent, TemplateMediaMeta]
];
export default Templates;