mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-19 05:46:04 +00:00
public commit
This commit is contained in:
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TemplateMediaComponent } from "./template.media.component";
|
||||
export { TemplateMediaMeta } from "./template.media.meta";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user