mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type { FieldApi, FormApi } from "@tanstack/react-form";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
EnumField,
|
||||
type Field,
|
||||
JsonField,
|
||||
JsonSchemaField,
|
||||
RelationField
|
||||
} from "data";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { useClient } from "ui/client";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "../../media/helper";
|
||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
Form: FormApi<any>;
|
||||
className?: string;
|
||||
action: "create" | "update";
|
||||
};
|
||||
|
||||
export function EntityForm({
|
||||
entity,
|
||||
entityId,
|
||||
handleSubmit,
|
||||
fieldsDisabled,
|
||||
Form,
|
||||
data,
|
||||
className,
|
||||
action
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
console.log("data", { data, fields });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Subscribe
|
||||
selector={(state) => {
|
||||
//console.log("state", state);
|
||||
return [state.canSubmit, state.isValid, state.errors];
|
||||
}}
|
||||
children={([canSubmit, isValid, errors]) => {
|
||||
//console.log("form:state", { canSubmit, isValid, errors });
|
||||
return (
|
||||
!isValid && (
|
||||
<div className="flex flex-col dark:bg-red-950 bg-red-100 p-4">
|
||||
<p>Form is invalid.</p>
|
||||
{Array.isArray(errors) && (
|
||||
<ul className="list-disc">
|
||||
{errors.map((error, key) => (
|
||||
<li className="ml-6" key={key}>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={className}>
|
||||
{fields.map((field, key) => {
|
||||
// @todo: tanstack form re-uses the state, causes issues navigating between entities w/ same fields
|
||||
|
||||
// media field needs to render outside of the form
|
||||
// as its value is not stored in the form state
|
||||
if (field instanceof MediaField) {
|
||||
return (
|
||||
<EntityMediaFormField
|
||||
key={field.name + key}
|
||||
entity={entity}
|
||||
entityId={entityId}
|
||||
formApi={Form}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.isFillable(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _key = `${entity.name}-${field.name}-${key}`;
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
key={_key}
|
||||
name={field.name}
|
||||
children={(props) => (
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="hidden">
|
||||
<button type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type EntityFormFieldProps<
|
||||
T extends keyof JSX.IntrinsicElements = "input",
|
||||
F extends Field = Field
|
||||
> = ComponentProps<T> & {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: F;
|
||||
action: "create" | "update";
|
||||
data?: EntityData;
|
||||
};
|
||||
|
||||
type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
|
||||
if (typeof e === "object" && "target" in e) {
|
||||
console.log("handleUpdate", e.target.value);
|
||||
fieldApi.handleChange(e.target.value);
|
||||
} else {
|
||||
console.log("handleUpdate-", e);
|
||||
fieldApi.handleChange(e);
|
||||
}
|
||||
});
|
||||
|
||||
//const required = field.isRequired();
|
||||
//const customFieldProps = { ...props, action, required };
|
||||
|
||||
if (field instanceof RelationField) {
|
||||
return (
|
||||
<EntityRelationalFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof JsonField) {
|
||||
return <EntityJsonFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
if (field instanceof JsonSchemaField) {
|
||||
return (
|
||||
<EntityJsonSchemaFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof EnumField) {
|
||||
return <EntityEnumFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
const fieldElement = field.getHtmlConfig().element;
|
||||
const fieldProps = field.getHtmlConfig().props as any;
|
||||
const Element = Formy.formElementFactory(fieldElement ?? "input", fieldProps);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Element
|
||||
{...fieldProps}
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityMediaFormField({
|
||||
formApi,
|
||||
field,
|
||||
entity,
|
||||
entityId,
|
||||
disabled
|
||||
}: {
|
||||
formApi: FormApi<any>;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
const client = useClient();
|
||||
const value = formApi.useStore((state) => {
|
||||
const val = state.values[field.name];
|
||||
if (!val || typeof val === "undefined") return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [val];
|
||||
});
|
||||
|
||||
const initialItems: FileState[] =
|
||||
value.length === 0
|
||||
? []
|
||||
: mediaItemsToFileStates(value, {
|
||||
baseUrl: client.baseUrl,
|
||||
overrides: { state: "uploaded" }
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent(() => {
|
||||
const api = client.media().api();
|
||||
return {
|
||||
url: api.getEntityUploadUrl(entity.name, entityId, field.name),
|
||||
headers: api.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file) => {
|
||||
client.__invalidate(entity.name, entityId);
|
||||
return await client.media().deleteFile(file);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel field={field} />
|
||||
<Dropzone
|
||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
initialItems={initialItems}
|
||||
maxItems={field.getMaxItems()}
|
||||
autoUpload
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityJsonFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: JsonField }) {
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
minHeight="100"
|
||||
/*required={field.isRequired()}*/
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
{/*<Formy.Textarea
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityEnumFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: EnumField }) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
fieldApi.handleChange(e.target.value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.Select
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate as any}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
>
|
||||
{!field.isRequired() && <option value="">- Select -</option>}
|
||||
{field.getOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Formy.Select>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import type { Entity, EntityData } from "data";
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbSelector,
|
||||
TbSquare,
|
||||
TbSquareCheckFilled
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
|
||||
export const Check = () => {
|
||||
const [checked, toggle] = useToggle([false, true]);
|
||||
const Icon = checked ? TbSquareCheckFilled : TbSquare;
|
||||
return (
|
||||
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type TableProps = {
|
||||
data: EntityData[];
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: EntityData) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
perPageOptions?: number[];
|
||||
sort?: { by?: string; dir?: "asc" | "desc" };
|
||||
onClickSort?: (name: string) => void;
|
||||
onClickPerPage?: (perPage: number) => void;
|
||||
classNames?: {
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const EntityTable: React.FC<TableProps> = ({
|
||||
data = [],
|
||||
entity,
|
||||
select,
|
||||
checkable,
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
perPageOptions,
|
||||
onClickPerPage,
|
||||
classNames
|
||||
}) => {
|
||||
select = select && select.length > 0 ? select : entity.getSelect();
|
||||
total = total || data.length;
|
||||
page = page || 1;
|
||||
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function handleSortClick(name: string) {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-y-scroll">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-background">
|
||||
<tr>
|
||||
{checkable && (
|
||||
<th align="center" className="w-[40px]">
|
||||
<Check />
|
||||
</th>
|
||||
)}
|
||||
{select.map((property, key) => {
|
||||
const field = getField(property)!;
|
||||
|
||||
return (
|
||||
<th key={key}>
|
||||
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
|
||||
<button
|
||||
type="button"
|
||||
className="link hover:bg-primary/5 pl-2.5 pr-1 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1"
|
||||
onClick={() => onClickSort?.(field.name)}
|
||||
>
|
||||
<span className="text-left text-nowrap whitespace-nowrap">
|
||||
{field.getLabel()}
|
||||
</span>
|
||||
<SortIndicator sort={sort} field={field.name} />
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, key) => {
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
data-border={key > 0}
|
||||
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
|
||||
onClick={() => onClickRow?.(row)}
|
||||
>
|
||||
{checkable && (
|
||||
<td align="center">
|
||||
<Check />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{Object.entries(row).map(([key, value], index) => {
|
||||
const field = getField(key);
|
||||
const _value = field?.getValue(value, "table");
|
||||
return (
|
||||
<td key={index}>
|
||||
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
|
||||
{value !== null && typeof value !== "undefined" ? (
|
||||
<span
|
||||
className={twMerge(classNames?.value, "line-clamp-2")}
|
||||
>
|
||||
{_value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-10 font-mono">null</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
|
||||
Per Page{" "}
|
||||
<Dropdown
|
||||
items={perPageOptions.map((perPage) => ({
|
||||
label: String(perPage),
|
||||
perPage
|
||||
}))}
|
||||
position="top-end"
|
||||
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
|
||||
>
|
||||
<Button>{perPage}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<TableProps, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
|
||||
return <TbArrowDown size={18} className="mt-[1px]" />;
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total }
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
};
|
||||
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Entity, EntityData } from "data";
|
||||
import { CellValue, DataTable, type DataTableProps } from "ui/components/table/DataTable";
|
||||
|
||||
type EntityTableProps<Data extends EntityData = EntityData> = Omit<
|
||||
DataTableProps<Data>,
|
||||
"columns"
|
||||
> & {
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
};
|
||||
|
||||
export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
|
||||
const columns = select ?? entity.getSelect();
|
||||
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function renderHeader(column: string) {
|
||||
try {
|
||||
const field = getField(column)!;
|
||||
return field.getLabel();
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render header", { entity, select, ...props }, e);
|
||||
return column;
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue({ value, property }) {
|
||||
let _value: any = value;
|
||||
try {
|
||||
const field = getField(property)!;
|
||||
_value = field.getValue(value, "table");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
|
||||
}
|
||||
|
||||
return <CellValue value={_value} property={property} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
{...props}
|
||||
columns={columns}
|
||||
renderHeader={renderHeader}
|
||||
renderValue={renderValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
114
app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx
Normal file
114
app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { layoutWithDagre } from "ui/components/canvas/layouts";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { EntityTableNode } from "./EntityTableNode";
|
||||
|
||||
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
|
||||
return Object.entries(entities ?? {}).map(([name, entity]) => {
|
||||
return {
|
||||
id: name,
|
||||
data: { label: name, ...entity },
|
||||
type: "entity",
|
||||
dragHandle: ".drag-handle",
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function relationsToEdges(relations: AppDataConfig["relations"]) {
|
||||
return Object.entries(relations ?? {}).map(([name, relation]) => {
|
||||
let sourceHandle = relation.source + `:${relation.target}`;
|
||||
if (relation.config?.mappedBy) {
|
||||
sourceHandle = `${relation.source}:${relation.config?.mappedBy}`;
|
||||
}
|
||||
if (relation.type !== "poly") {
|
||||
sourceHandle += "_id";
|
||||
}
|
||||
|
||||
return {
|
||||
id: name,
|
||||
source: relation.source,
|
||||
target: relation.target,
|
||||
sourceHandle,
|
||||
targetHandle: relation.target + ":id"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
entity: EntityTableNode.Component
|
||||
} as const;
|
||||
|
||||
export function DataSchemaCanvas() {
|
||||
const {
|
||||
config: { data }
|
||||
} = useBknd();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
const nodes = entitiesToNodes(data.entities);
|
||||
const edges = relationsToEdges(data.relations).map((e) => ({
|
||||
...e,
|
||||
style: {
|
||||
stroke: theme === "light" ? "#ccc" : "#666"
|
||||
},
|
||||
type: "smoothstep",
|
||||
markerEnd: {
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777"
|
||||
}
|
||||
}));
|
||||
|
||||
const nodeLayout = layoutWithDagre({
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
...EntityTableNode.getSize(n)
|
||||
})),
|
||||
edges,
|
||||
graph: {
|
||||
rankdir: "LR",
|
||||
//align: "UR",
|
||||
ranker: "network-simplex",
|
||||
nodesep: 350,
|
||||
ranksep: 50
|
||||
}
|
||||
});
|
||||
|
||||
nodeLayout.nodes.forEach((node) => {
|
||||
const n = nodes.find((n) => n.id === node.id);
|
||||
if (n) {
|
||||
n.position = { x: node.x, y: node.y };
|
||||
}
|
||||
});
|
||||
|
||||
/*const _edges = edges.map((e) => ({
|
||||
...e,
|
||||
source: e.source + `-${e.target}_id`,
|
||||
target: e.target + "-id"
|
||||
}));*/
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<Canvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
fitViewOptions={{
|
||||
minZoom: 0.1,
|
||||
maxZoom: 0.8
|
||||
}}
|
||||
>
|
||||
<Panels zoom minimap />
|
||||
</Canvas>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
124
app/src/ui/modules/data/components/canvas/EntityTableNode.tsx
Normal file
124
app/src/ui/modules/data/components/canvas/EntityTableNode.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
|
||||
import type { TAppDataEntity } from "data/data-schema";
|
||||
import { useState } from "react";
|
||||
import { TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
|
||||
export type TableProps = {
|
||||
name: string;
|
||||
type?: string;
|
||||
fields: TableField[];
|
||||
};
|
||||
export type TableField = {
|
||||
name: string;
|
||||
type: string;
|
||||
primary?: boolean;
|
||||
foreign?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { data } = props;
|
||||
const fields = props.data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
//const flow = useReactFlow();
|
||||
//const flow = useTestContext();
|
||||
|
||||
return (
|
||||
<DefaultNode selected={props.selected}>
|
||||
<DefaultNode.Header label={data.label} />
|
||||
<div>
|
||||
{Object.entries(fields).map(([name, field], index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
field={{ name, ...field }}
|
||||
table={data.label}
|
||||
index={index}
|
||||
last={field_count === index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DefaultNode>
|
||||
);
|
||||
}
|
||||
|
||||
const handleStyle = {
|
||||
background: "transparent",
|
||||
border: "none"
|
||||
};
|
||||
const TableRow = ({
|
||||
field,
|
||||
table,
|
||||
index,
|
||||
onHover,
|
||||
last
|
||||
}: {
|
||||
field: TableField;
|
||||
table: string;
|
||||
index: number;
|
||||
last?: boolean;
|
||||
onHover?: (hovered: boolean) => void;
|
||||
}) => {
|
||||
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
|
||||
const handles = true;
|
||||
const handleId = `${table}:${field.name}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto",
|
||||
last && "rounded-bl-lg rounded-br-lg",
|
||||
"hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{handles && (
|
||||
<Handle
|
||||
title={handleId}
|
||||
type="source"
|
||||
id={handleId}
|
||||
position={Position.Left}
|
||||
style={{ top: handleTop, left: 0, ...handleStyle }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-6 pr-1.5 justify-center items-center">
|
||||
{field.type === "primary" && <TbKey className="text-yellow-700" />}
|
||||
{field.type === "relation" && <TbDiamonds className="text-sky-700" />}
|
||||
</div>
|
||||
<div className="flex flex-grow">{field.name}</div>
|
||||
<div className="flex opacity-60">{field.type}</div>
|
||||
|
||||
{handles && (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
title={handleId}
|
||||
id={handleId}
|
||||
position={Position.Right}
|
||||
style={{ top: handleTop, right: -5, ...handleStyle }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HEIGHTS = {
|
||||
header: 30,
|
||||
row: 32.5
|
||||
};
|
||||
|
||||
export const EntityTableNode = {
|
||||
Component: NodeComponent,
|
||||
getSize: (data: TAppDataEntity) => {
|
||||
const fields = data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
return {
|
||||
width: 320,
|
||||
height: HEIGHTS.header + HEIGHTS.row * field_count
|
||||
};
|
||||
}
|
||||
};
|
||||
80
app/src/ui/modules/data/components/fields-specs.ts
Normal file
80
app/src/ui/modules/data/components/fields-specs.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
TbBraces,
|
||||
TbCalendar,
|
||||
TbCirclesRelation,
|
||||
TbCodePlus,
|
||||
TbNumber123,
|
||||
TbPhoto,
|
||||
TbSelector,
|
||||
TbTextCaption,
|
||||
TbToggleLeft
|
||||
} from "react-icons/tb";
|
||||
|
||||
type TFieldSpec = {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
addable?: boolean;
|
||||
disabled?: string[];
|
||||
hidden?: string[];
|
||||
};
|
||||
|
||||
export const fieldSpecs: TFieldSpec[] = [
|
||||
{
|
||||
type: "primary",
|
||||
label: "Primary",
|
||||
icon: TbTextCaption,
|
||||
addable: false,
|
||||
disabled: ["name"],
|
||||
hidden: ["virtual"]
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Text",
|
||||
icon: TbTextCaption
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
label: "Number",
|
||||
icon: TbNumber123
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
label: "Boolean",
|
||||
icon: TbToggleLeft
|
||||
},
|
||||
{
|
||||
type: "date",
|
||||
label: "Date",
|
||||
icon: TbCalendar
|
||||
},
|
||||
{
|
||||
type: "enum",
|
||||
label: "Enum",
|
||||
icon: TbSelector
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
label: "JSON",
|
||||
icon: TbBraces
|
||||
},
|
||||
{
|
||||
type: "jsonschema",
|
||||
label: "JSON Schema",
|
||||
icon: TbCodePlus
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
label: "Relation",
|
||||
icon: TbCirclesRelation,
|
||||
addable: false,
|
||||
hidden: ["virtual"]
|
||||
},
|
||||
{
|
||||
type: "media",
|
||||
label: "Media",
|
||||
icon: TbPhoto,
|
||||
addable: false,
|
||||
hidden: ["virtual"]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import type { EntityData, JsonSchemaField } from "data";
|
||||
import { Suspense, lazy } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
|
||||
export function EntityJsonSchemaFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
data,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: JsonSchemaField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
}) {
|
||||
function handleChange(value: any) {
|
||||
if (disabled) return;
|
||||
fieldApi.setValue(value);
|
||||
}
|
||||
|
||||
const formData = data?.[field.name];
|
||||
//console.log("formData", { disabled, formData });
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<div
|
||||
data-disabled={disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<JsonSchemaForm
|
||||
schema={field.getJsonSchema()}
|
||||
onChange={handleChange}
|
||||
direction="horizontal"
|
||||
formData={formData}
|
||||
uiSchema={{
|
||||
"ui:globalOptions": { flexDirection: "row" },
|
||||
...field.getJsonUiSchema()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getHotkeyHandler, useHotkeys } from "@mantine/hooks";
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { EntityData, RelationField } from "data";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import { Button } from "ui";
|
||||
import { useBknd, useClient } from "ui/client";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { useEntities } from "ui/container";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { EntityTable } from "../EntityTable";
|
||||
|
||||
// @todo: allow clear if not required
|
||||
export function EntityRelationalFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
data,
|
||||
disabled,
|
||||
tabIndex
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: RelationField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
}) {
|
||||
const { app } = useBknd();
|
||||
const entity = app.entity(field.target())!;
|
||||
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
|
||||
const [location, navigate] = useLocation();
|
||||
const ref = useRef<any>(null);
|
||||
const client = useClient();
|
||||
const container = useEntities(
|
||||
field.target(),
|
||||
{
|
||||
limit: query.limit,
|
||||
offset: (query.page - 1) * query.limit
|
||||
//select: entity.getSelect(undefined, "form")
|
||||
},
|
||||
{ enabled: true }
|
||||
);
|
||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||
|
||||
const referenceField = data?.[field.reference()];
|
||||
const relationalField = data?.[field.name];
|
||||
|
||||
useEffect(() => {
|
||||
_setValue(data?.[field.reference()]);
|
||||
}, [referenceField]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const rel_value = field.target();
|
||||
if (!rel_value || !relationalField) return;
|
||||
|
||||
console.log("-- need to fetch", field.target(), relationalField);
|
||||
const fetched = await client.api.data.readOne(field.target(), relationalField);
|
||||
if (fetched.res.ok && fetched.data) {
|
||||
_setValue(fetched.data as any);
|
||||
}
|
||||
console.log("-- fetched", fetched);
|
||||
|
||||
console.log("relation", {
|
||||
referenceField,
|
||||
relationalField,
|
||||
data,
|
||||
field,
|
||||
entity
|
||||
});
|
||||
})();
|
||||
}, [relationalField]);
|
||||
|
||||
/*const initialValue: { id: number | undefined; [key: string]: any } = data?.[
|
||||
field.reference()
|
||||
] ?? {
|
||||
id: data?.[field.name],
|
||||
};*/
|
||||
|
||||
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("yo");
|
||||
if (_value) {
|
||||
navigate(routes.data.entity.edit(entity.name, _value.id as any));
|
||||
}
|
||||
}
|
||||
|
||||
/*console.log(
|
||||
"relationfield:data",
|
||||
{ _value, initialValue },
|
||||
data,
|
||||
field.reference(),
|
||||
entity,
|
||||
//container.entity,
|
||||
//data[field.reference()],
|
||||
data?.[field.name],
|
||||
field,
|
||||
);*/
|
||||
|
||||
// fix missing value on fields that are required
|
||||
useEffect(() => {
|
||||
if (field.isRequired() && !fieldApi.state.value) {
|
||||
fieldApi.setValue(container.data?.[0]?.id);
|
||||
}
|
||||
}, [container.data]);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<div
|
||||
data-disabled={!Array.isArray(container.data) || disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<Popover
|
||||
backdrop
|
||||
className=""
|
||||
target={({ toggle }) => (
|
||||
<PopoverTable
|
||||
container={container}
|
||||
entity={entity}
|
||||
query={query}
|
||||
toggle={toggle}
|
||||
onClickRow={(row) => {
|
||||
fieldApi.setValue(row.id);
|
||||
_setValue(row as any);
|
||||
toggle();
|
||||
}}
|
||||
onClickPage={(page) => {
|
||||
console.log("setting page", page);
|
||||
setQuery((prev) => ({ ...prev, page }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-muted/40 w-full h-11 focus:bg-muted rounded-md items-center px-2.5 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 cursor-pointer active:bg-muted/80 hover:bg-muted/60 flex flex-row gap-2"
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
ref.current?.click();
|
||||
}
|
||||
]
|
||||
])}
|
||||
>
|
||||
{_value ? (
|
||||
<>
|
||||
<div className="flex bg-primary/10 px-2 leading-none py-1.5 rounded-lg font-mono text-sm">
|
||||
{ucFirst(entity.name)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 overflow-clip w-1 flex-grow">
|
||||
{_value &&
|
||||
Object.entries(_value).map(([key, value]) => {
|
||||
const field = entity.getField(key)!;
|
||||
if (field.isHidden("table")) return null;
|
||||
|
||||
const _value = field.getValue(value, "table");
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-row gap-1 items-center flex-nowrap"
|
||||
>
|
||||
<span className="opacity-60 text-nowrap">
|
||||
{field.getLabel()}:
|
||||
</span>{" "}
|
||||
{_value !== null && typeof value !== "undefined" ? (
|
||||
<span className="text-nowrap truncate">{_value}</span>
|
||||
) : (
|
||||
<span className="opacity-30 text-nowrap font-mono mt-0.5">
|
||||
null
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button IconLeft={TbEye} onClick={handleViewItem} size="small">
|
||||
View
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="pl-2">- Select -</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<Formy.Input
|
||||
type="hidden"
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value ?? ""}
|
||||
onChange={console.log}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{/*<Formy.Select
|
||||
ref={ref}
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
data-value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
disabled={!Array.isArray(container.data)}
|
||||
>
|
||||
{container.data ? (
|
||||
<>
|
||||
{emptyOption}
|
||||
{!field.isRequired() && emptyOption}
|
||||
{container.data?.map(renderRow)}
|
||||
</>
|
||||
) : (
|
||||
<option value={undefined} disabled>
|
||||
Loading...
|
||||
</option>
|
||||
)}
|
||||
</Formy.Select>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }) => {
|
||||
function handleNext() {
|
||||
if (query.limit * query.page < container.meta?.count) {
|
||||
onClickPage(query.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (query.page > 1) {
|
||||
onClickPage(query.page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys([
|
||||
["ArrowRight", handleNext],
|
||||
["ArrowLeft", handlePrev],
|
||||
["Escape", toggle]
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityTable
|
||||
classNames={{ value: "line-clamp-1 truncate max-w-52 text-nowrap" }}
|
||||
data={container.data ?? []}
|
||||
entity={entity}
|
||||
total={container.meta?.count}
|
||||
page={query.page}
|
||||
onClickRow={onClickRow}
|
||||
onClickPage={onClickPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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