public commit

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

View File

@@ -0,0 +1,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>
);
}

View 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>
));
};

View 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}
/>
);
}

View 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>
);
}

View 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
};
}
};

View 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"]
}
];

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
import { useForm } from "@tanstack/react-form";
import type { Entity, EntityData } from "data";
import { getChangeSet, getDefaultValues } from "data/helper";
type EntityFormProps = {
action: "create" | "update";
entity: Entity;
initialData?: EntityData | null;
onSubmitted?: (changeSet?: EntityData) => Promise<void>;
};
export function useEntityForm({
action = "update",
entity,
initialData,
onSubmitted
}: EntityFormProps) {
const data = initialData ?? {};
// @todo: check if virtual must be filtered
const fields = entity.getFillableFields(action, true);
console.log("useEntityForm:data", data);
// filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data);
console.log("useEntityForm:defaultValues", data);
const Form = useForm({
defaultValues,
validators: {
onSubmitAsync: async ({ value }): Promise<any> => {
try {
console.log("validating", value, entity.isValidData(value, action));
entity.isValidData(value, action, true);
return undefined;
} catch (e) {
//console.log("---validation error", e);
return (e as Error).message;
}
}
},
onSubmit: async ({ value, formApi }) => {
console.log("onSubmit", value);
if (!entity.isValidData(value, action)) {
console.error("invalid data", value);
return;
}
//await new Promise((resolve) => setTimeout(resolve, 1000));
if (!data) return;
const changeSet = getChangeSet(action, value, data, fields);
console.log("changesSet", action, changeSet);
// only submit change set if there were changes
await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet);
}
});
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
e.stopPropagation();
//console.log("handlesubmit");
void Form.handleSubmit();
}
return { Form, handleSubmit, fields, action, values: defaultValues, initialData };
}