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,117 @@
import { type FieldApi, useForm } from "@tanstack/react-form";
import { Type, type TypeInvalidError, parse } from "core/utils";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
type LoginFormProps = {
onSubmitted?: (value: { email: string; password: string }) => Promise<void>;
};
export function LoginForm({ onSubmitted }: LoginFormProps) {
const form = useForm({
defaultValues: {
email: "",
password: ""
},
onSubmit: async ({ value }) => {
onSubmitted?.(value);
},
defaultState: {
canSubmit: false,
isValid: false
},
validatorAdapter: () => {
function validate(
{ value, fieldApi }: { value: any; fieldApi: FieldApi<any, any> },
fn: any
): any {
if (fieldApi.form.state.submissionAttempts === 0) return;
try {
parse(fn, value);
} catch (e) {
return (e as TypeInvalidError).errors
.map((error) => error.schema.error ?? error.message)
.join(", ");
}
}
return { validate, validateAsync: validate };
}
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate>
<form.Field
name="email"
validators={{
onChange: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Email address</Formy.Label>
<Formy.Input
type="email"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group>
)}
/>
<form.Field
name="password"
validators={{
onChange: Type.String({
minLength: 8
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Password</Formy.Label>
<Formy.Input
type="password"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group>
)}
/>
<form.Subscribe
selector={(state) => {
//console.log("state", state, Object.values(state.fieldMeta));
const fieldMeta = Object.values(state.fieldMeta).map((f) => f.isDirty);
const allDirty = fieldMeta.length > 0 ? fieldMeta.reduce((a, b) => a && b) : false;
return [allDirty, state.isSubmitting];
}}
children={([allDirty, isSubmitting]) => {
return (
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={!allDirty || isSubmitting}
>
Sign in
</Button>
);
}}
/>
</form>
);
}

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

View File

@@ -0,0 +1,158 @@
import {
Background,
BackgroundVariant,
Controls,
type Edge,
MiniMap,
type Node,
type NodeChange,
ReactFlow,
addEdge,
useEdgesState,
useNodesState,
useStore
} from "@xyflow/react";
import { type Execution, ExecutionEvent, ExecutionState, type Flow, type Task } from "flows";
import { transform } from "lodash-es";
import { useCallback, useEffect, useMemo } from "react";
//import "reactflow/dist/style.css";
import { getFlowEdges, getFlowNodes, getNodeTypes } from "../utils";
import { FetchTaskComponent } from "./tasks/FetchTaskComponent";
import { TaskComponent } from "./tasks/TaskComponent";
export default function FlowCanvas({
flow,
execution,
options = {
theme: "dark"
}
}: {
flow: Flow;
execution: Execution | undefined;
options?: { theme?: string };
}) {
const nodes = getFlowNodes(flow);
const edges = getFlowEdges(flow);
console.log("nodes", nodes);
console.log("edges", edges);
const nodeTypes = getNodeTypes(flow);
//console.log("nodeTypes", nodeTypes);
return (
<RenderedFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
execution={execution}
theme={options.theme}
/>
);
}
function RenderedFlow({ nodes, edges, nodeTypes, execution, theme }: any) {
const [_nodes, setNodes, onNodesChange] = useNodesState(nodes);
const [_edges, setEdges, onEdgesChange] = useEdgesState(edges);
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
useEffect(() => {
execution?.subscribe(async (event: ExecutionEvent | ExecutionState) => {
if (event instanceof ExecutionEvent) {
setNodes((nodes) => {
return nodes.map((node) => {
// @ts-ignore
if (node.data.task && node.data.task.name === event.task().name) {
return {
...node,
data: {
...node.data,
state: {
// @ts-ignore
...node.data.state,
event
}
}
};
}
return node;
});
});
} else if (event instanceof ExecutionState) {
if (event.params.state === "started") {
console.log("!!!!! started");
setNodes((nodes) => {
return nodes.map((node) => ({
...node,
data: {
...node.data,
state: {
// @ts-ignore
...node.data.state,
event: undefined
}
}
}));
});
} else {
console.log("---result", execution?.getResponse());
}
console.log("!!! ExecutionState", event, event.params.state);
}
/*console.log(
"[event--]",
event.isStart() ? "start" : "end",
event.task().name,
event.isStart() ? undefined : event.succeeded(),
);*/
});
}, [execution]);
function handleNodeClick(event: React.MouseEvent, _node: Node) {
console.log("node click", _node);
}
function handleNodesChange(changes: NodeChange[]) {
console.log("changes", changes);
}
return (
<ReactFlow
nodes={_nodes}
edges={_edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgeClick={(e, edge) => console.log("edge clicked", edge)}
onNodeClick={handleNodeClick}
nodeDragThreshold={10}
nodeTypes={nodeTypes}
onConnect={onConnect}
fitView
fitViewOptions={{ maxZoom: 1 }}
proOptions={{
hideAttribution: true
}}
>
<Controls>
<ZoomState />
</Controls>
<MiniMap />
<Background
key={theme}
color={theme === "dark" ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.2)"}
variant={BackgroundVariant.Dots}
gap={20}
size={1.5}
/>
</ReactFlow>
);
}
const zoomStore = (state) => {
return state.transform[2];
};
const ZoomState = () => {
const zoom = useStore(zoomStore);
return <div>{Math.ceil(zoom * 100).toFixed(0)}%</div>;
};

View File

@@ -0,0 +1,71 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Const, Type, transformObject } from "core/utils";
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows";
import { Suspense, lazy } from "react";
import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
Icon?: IconType;
children?: React.ReactNode;
};
const triggerSchemas = Object.values(
transformObject(TriggerMap, (trigger, name) =>
Type.Object(
{
type: Const(name),
config: trigger.cls.schema
},
{ title: String(name), additionalProperties: false }
)
)
);
export function TriggerComponent({
children,
Icon = TbCircleLetterT,
...props
}: TaskComponentProps) {
const { trigger } = props.data;
return (
<>
<div
data-selected={props.selected ? 1 : undefined}
className="flex flex-col rounded bg-background/80 border border-muted data-[selected]:bg-background data-[selected]:ring-2 ring-primary/40 w-[500px] cursor-auto"
>
<div className="flex flex-row gap-2 px-3 py-2 items-center justify-between drag-handle cursor-grab">
<div className="flex flex-row gap-2 items-center">
<Icon size={18} />
<div className="font-medium">{trigger.type}</div>
</div>
</div>
<div className="w-full h-px bg-primary/10" />
<div className="flex flex-col gap-2 px-3 py-2">
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={Type.Union(triggerSchemas)}
onChange={console.log}
formData={trigger}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
</div>
</div>
<Handle
type="source"
position={props.sourcePosition ?? Position.Bottom}
isConnectable={props.isConnectable}
/>
</>
);
}

View File

@@ -0,0 +1,41 @@
import type { Task } from "flows";
import { Suspense, lazy } from "react";
import { TemplateField } from "./TemplateField";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export type TaskFormProps = {
task: Task;
onChange?: (values: any) => void;
[key: string]: any;
};
export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
// @ts-ignore
const schema = task.constructor.schema;
const params = task.params;
const uiSchema = Object.fromEntries(
Object.keys(schema.properties).map((key) => {
return [key, { "ui:field": "template", "ui:fieldReplacesAnyOrOneOf": true }];
})
);
//console.log("uiSchema", uiSchema);
return (
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={schema}
onChange={onChange}
formData={params}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
);
}

View File

@@ -0,0 +1,94 @@
import type { FieldProps, FormContextType, RJSFSchema, StrictRJSFSchema } from "@rjsf/utils";
import { SimpleRenderer } from "core";
import { ucFirst, ucFirstAll } from "core/utils";
import { useState } from "react";
const modes = ["field", "code"] as const;
type Mode = (typeof modes)[number];
export function TemplateField<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: FieldProps<T, S, F>) {
const formData = props.formData;
const hasMarkup = SimpleRenderer.hasMarkup(formData!);
const [mode, setMode] = useState<Mode>(hasMarkup ? "code" : "field");
const [values, setValues] = useState<Record<Mode, any>>({
field: hasMarkup ? "" : formData,
code: hasMarkup ? formData : ""
});
//console.log("TemplateField", props);
const { SchemaField } = props.registry.fields;
const { schema } = props;
function handleModeSwitch(mode: Mode) {
setMode(mode);
props.onChange(values[mode]);
}
function onChange(value: any) {
setValues({ ...values, [mode]: value });
props.onChange(value);
}
let _schema: any = schema;
if (!("anyOf" in schema)) {
_schema = {
anyOf: [schema, { type: "string" }]
};
}
const [fieldSchema, codeSchema] = _schema.anyOf;
const currentSchema = mode === "field" ? fieldSchema : codeSchema;
const currentValue = values[mode];
const uiSchema =
mode === "field"
? { "ui:label": false }
: {
"ui:label": false,
"ui:widget": "textarea",
"ui:options": { rows: 1 }
};
return (
<div className="flex flex-col gap-2 flex-grow">
<label className="flex flex-row gap-2 w-full justify-between">
{ucFirstAll(props.name)}
<div className="flex flex-row gap-3 items-center">
{modes.map((m) => (
<button
data-active={m === mode ? 1 : undefined}
className="leading-none text-sm pb-0.5 border-b border-b-transparent font-mono opacity-50 data-[active]:border-b-primary/50 data-[active]:opacity-100"
role="button"
key={m}
onClick={() => handleModeSwitch(m)}
>
{ucFirst(m)}
</button>
))}
</div>
</label>
<div className="flex flex-col flex-grow items-stretch justify-stretch">
{/* @ts-ignore */}
<SchemaField
uiSchema={uiSchema}
schema={currentSchema}
registry={props.registry}
idSchema={props.idSchema}
onFocus={props.onFocus}
onBlur={props.onBlur}
formData={currentValue}
onChange={onChange}
disabled={props.disabled}
readonly={props.readonly}
required={props.required}
autofocus={props.autofocus}
rawErrors={props.rawErrors}
name={props.name}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { FetchTask, TaskRenderProps } from "flows";
import { TbGlobe, TbWorld } from "react-icons/tb";
import { TaskComponent } from "./TaskComponent";
export function FetchTaskComponent(props: TaskRenderProps<FetchTask<any>>) {
const { task, state } = props.data;
return (
<TaskComponent {...props} Icon={TbWorld}>
<div>
<div>URL: {task.params.url}</div>
<div>Method: {task.params.method}</div>
<div>Headers: {JSON.stringify(task.params.headers)}</div>
<div>Body: {JSON.stringify(task.params.body)}</div>
</div>
</TaskComponent>
);
}

View File

@@ -0,0 +1,19 @@
import { TaskForm } from "../form/TaskForm";
import { TaskComponent, type TaskComponentProps } from "./TaskComponent";
export function RenderTaskComponent(props: TaskComponentProps) {
const { task } = props.data;
return (
<TaskComponent {...props}>
<TaskForm
task={task}
onChange={console.log}
uiSchema={{
render: {
"ui:field": "LiquidJsField"
}
}}
/>
</TaskComponent>
);
}

View File

@@ -0,0 +1,52 @@
import { Handle, Position } from "@xyflow/react";
import type { TaskRenderProps } from "flows";
import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb";
import { TaskForm } from "../form/TaskForm";
export type TaskComponentProps = TaskRenderProps & {
Icon?: IconType;
children?: React.ReactNode;
};
export function TaskComponent({ children, Icon = TbCircleLetterT, ...props }: TaskComponentProps) {
const { task, state } = props.data;
return (
<>
<Handle
type="target"
position={props.targetPosition ?? Position.Top}
isConnectable={props.isConnectable}
/>
<div
data-selected={props.selected ? 1 : undefined}
className="flex flex-col rounded bg-background/80 border border-muted data-[selected]:bg-background data-[selected]:ring-2 ring-primary/40 w-[500px] cursor-auto"
>
<div className="flex flex-row gap-2 px-3 py-2 items-center justify-between drag-handle cursor-grab">
<div className="flex flex-row gap-2 items-center">
<Icon size={18} />
<div className="font-medium">{task.label}</div>
</div>
<div
data-state={state.event?.getState() ?? "idle"}
className="px-1.5 bg-primary/10 rounded leading-0 data-[state=running]:bg-yellow-500/30 data-[state=success]:bg-green-800/30 data-[state=error]:bg-red-800"
>
{state.event?.getState() ?? "idle"}
</div>
</div>
<div className="w-full h-px bg-primary/10" />
<div className="flex flex-col gap-2 px-3 py-2">
{children ?? <TaskForm task={task} onChange={console.log} />}
</div>
</div>
{!state.isRespondingTask && (
<Handle
type="source"
position={props.sourcePosition ?? Position.Bottom}
isConnectable={props.isConnectable}
/>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
import { FetchTaskComponent } from "./FetchTaskComponent";
import { TaskComponent } from "./TaskComponent";

View File

@@ -0,0 +1,109 @@
import { Input, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
const ITEM = { key: "", value: "" };
export type KeyValueInputProps = {
label?: string;
classNames?: {
label?: string;
itemWrapper?: string;
};
initialValue?: Record<string, string>;
onChange?: (value: Record<string, string> | (typeof ITEM)[]) => void;
mode?: "object" | "array";
error?: string | any;
};
function toItems(obj: Record<string, string>) {
if (!obj || Array.isArray(obj)) return [ITEM];
return Object.entries(obj).map(([key, value]) => ({ key, value }));
}
export const KeyValueInput: React.FC<KeyValueInputProps> = ({
label,
initialValue,
onChange,
error,
classNames,
mode = "object"
}) => {
const [items, setItems] = useState(initialValue ? toItems(initialValue) : [ITEM]);
useEffect(() => {
if (onChange) {
if (mode === "object") {
const value = items.reduce((acc, item) => {
if (item.key && typeof item.value !== "undefined") {
acc[item.key] = item.value;
}
return acc;
}, {});
onChange(value);
} else {
onChange(items);
}
}
}, [items]);
function handleAdd() {
setItems((prev) => [...prev, ITEM]);
}
function handleUpdate(i: number, attr: string) {
return (e) => {
const value = e.currentTarget.value;
setItems((prev) => {
return prev.map((item, index) => {
if (index === i) {
return { ...item, [attr]: value };
}
return item;
});
});
};
}
function handleRemove(i: number) {
return () => {
setItems((prev) => prev.filter((_, index) => index !== i));
};
}
return (
<Input.Wrapper className="w-full">
<div className="flex flex-row w-full justify-between">
{label ? <Input.Label className={classNames?.label}>{label}</Input.Label> : <div />}
<IconButton Icon={IconPlus as any} size="xs" onClick={handleAdd} />
</div>
<div className={twMerge("flex flex-col gap-2", classNames?.itemWrapper)}>
{items.map(({ key, value }, i) => (
<div key={i} className="flex flex-row gap-2 items-center">
{items.length > 1 && (
<IconButton Icon={IconTrash as any} size="xs" onClick={handleRemove(i)} />
)}
<TextInput
className="w-36"
placeholder="key"
value={key}
classNames={{ wrapper: "font-mono pt-px" }}
onChange={handleUpdate(i, "key")}
/>
<TextInput
className="w-full"
placeholder="value"
value={value}
classNames={{ wrapper: "font-mono pt-px" }}
onChange={handleUpdate(i, "value")}
/>
</div>
))}
{error && <Input.Error>{error}</Input.Error>}
</div>
{/*<pre>{JSON.stringify(items, null, 2)}</pre>*/}
</Input.Wrapper>
);
};

View File

@@ -0,0 +1,79 @@
import type { ElementProps } from "@mantine/core";
import { Panel, type PanelPosition } from "@xyflow/react";
import { type HTMLAttributes, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
import { IconButton as _IconButton } from "ui/components/buttons/IconButton";
export type FlowPanel = HTMLAttributes<HTMLDivElement> & {
position: PanelPosition;
unstyled?: boolean;
};
export function FlowPanel({ position, className, children, unstyled, ...props }: FlowPanel) {
if (unstyled) {
return (
<Panel
position={position}
className={twMerge("flex flex-row p-1 gap-4", className)}
{...props}
>
{children}
</Panel>
);
}
return (
<Panel position={position} {...props}>
<Wrapper className={className}>{children}</Wrapper>
</Panel>
);
}
const Wrapper = ({ children, className, ...props }: ElementProps<"div">) => (
<div
{...props}
className={twMerge(
"flex flex-row bg-lightest border ring-2 ring-muted/5 border-muted rounded-full items-center p-1",
className
)}
>
{children}
</div>
);
const IconButton = ({
Icon,
size = "lg",
variant = "ghost",
onClick,
disabled,
className,
round,
...rest
}: ElementProps<typeof _IconButton> & { round?: boolean }) => (
<_IconButton
Icon={Icon}
size={size}
variant={variant}
onClick={onClick}
disabled={disabled}
className={twMerge(round ? "rounded-full" : "", className)}
{...rest}
/>
);
const Text = forwardRef<any, ElementProps<"span"> & { mono?: boolean }>(
({ children, className, mono, ...props }, ref) => (
<span
{...props}
ref={ref}
className={twMerge("text-md font-medium leading-none", mono && "font-mono", className)}
>
{children}
</span>
)
);
FlowPanel.Wrapper = Wrapper;
FlowPanel.IconButton = IconButton;
FlowPanel.Text = Text;

View File

@@ -0,0 +1,148 @@
import { type ElementProps, Tabs } from "@mantine/core";
import { IconBoltFilled } from "@tabler/icons-react";
import type { Node, NodeProps } from "@xyflow/react";
import { useState } from "react";
import { TbDots, TbPlayerPlayFilled } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
import type { TFlowNodeData } from "../../hooks/use-flow";
type BaseNodeProps = NodeProps<Node<TFlowNodeData>> & {
children?: React.ReactNode | React.ReactNode[];
className?: string;
Icon?: React.FC<any>;
onChangeName?: (name: string) => void;
isInvalid?: boolean;
tabs?: {
id: string;
label: string;
content: () => React.ReactNode;
}[];
};
export function BaseNode({ children, className, tabs, Icon, isInvalid, ...props }: BaseNodeProps) {
const { data } = props;
function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
if (props.onChangeName) {
props.onChangeName(e.target.value);
}
}
return (
<DefaultNode
className={twMerge(
"w-96",
//props.selected && "ring-4 ring-blue-500/15",
isInvalid && "ring-8 ring-red-500/15",
className
)}
>
<Header
Icon={Icon ?? IconBoltFilled}
initialValue={data.label}
onChange={handleNameChange}
/>
<DefaultNode.Content className="gap-3">{children}</DefaultNode.Content>
<BaseNodeTabs tabs={tabs} />
</DefaultNode>
);
}
const BaseNodeTabs = ({ tabs }: { tabs: BaseNodeProps["tabs"] }) => {
const [active, setActive] = useState<number>();
if (!tabs || tabs?.length === 0) return null;
function handleClick(i: number) {
return () => {
setActive((prev) => (prev === i ? undefined : i));
};
}
return (
<div className="border-t border-t-muted mt-1">
<div className="flex flex-row justify-start bg-primary/5 px-3 py-2.5 gap-3">
{tabs.map((tab, i) => (
<button
type="button"
key={tab.id}
onClick={handleClick(i)}
className={twMerge(
"text-sm leading-none",
i === active ? "font-bold opacity-80" : "font-medium opacity-50"
)}
>
{tab.label}
</button>
))}
</div>
{typeof active !== "undefined" ? (
<div className="border-t border-t-muted">{tabs[active]?.content()}</div>
) : null}
</div>
);
};
const Header = ({
Icon,
iconProps,
rightSection,
initialValue,
changable = false,
onChange
}: {
Icon: React.FC<any>;
iconProps?: ElementProps<"svg">;
rightSection?: React.ReactNode;
initialValue: string;
changable?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => {
const [value, setValue] = useState(initialValue);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!changable) return;
const v = String(e.target.value);
if (v.length > 0 && !/^[a-zA-Z_][a-zA-Z0-9_ ]*$/.test(v)) {
return;
}
if (v.length === 25) {
return;
}
const clean = v.toLowerCase().replace(/ /g, "_").replace(/__+/g, "_");
setValue(clean);
onChange?.({ ...e, target: { ...e.target, value: clean } });
}
return (
<DefaultNode.Header className="justify-between gap-10">
<div className="flex flex-row flex-grow gap-1 items-center">
<Icon {...{ width: 16, height: 16, ...(iconProps ?? {}) }} />
{changable ? (
<input
type="text"
value={value}
disabled={!changable}
onChange={handleChange}
className={twMerge(
"font-mono font-semibold bg-transparent rounded-lg outline-none pl-1.5 w-full hover:bg-lightest/30 transition-colors focus:bg-lightest/60"
)}
/>
) : (
<span className="font-mono font-semibold bg-transparent rounded-lg outline-none pl-1.5">
{value}
</span>
)}
</div>
<div className="flex flex-row gap-1">
{/*{rightSection}*/}
<IconButton Icon={TbPlayerPlayFilled} size="sm" />
<IconButton Icon={TbDots} size="sm" />
</div>
</DefaultNode.Header>
);
};

View File

@@ -0,0 +1,31 @@
import { type HandleProps, Position, Handle as XYFlowHandle } from "@xyflow/react";
export function Handle(props: Omit<HandleProps, "position">) {
const base = {
top: 16,
width: 10,
height: 10,
background: "transparent",
border: "2px solid #999"
};
const offset = -10;
const styles = {
target: {
...base,
left: offset
},
source: {
...base,
right: offset
}
};
//console.log("type", props.type, styles[props.type]);
return (
<XYFlowHandle
{...props}
position={props.type === "source" ? Position.Right : Position.Left}
style={styles[props.type]}
/>
);
}

View File

@@ -0,0 +1,107 @@
import { useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
import { useFlowCanvas } from "../../hooks/use-flow";
import { Handle } from "./Handle";
const nodes = [
{
type: "fetch",
label: "Fetch",
description: "Fetch data from a URL",
template: {
type: "fetch",
params: {
method: "GET",
headers: [],
url: ""
}
}
},
{
type: "render",
label: "Render",
description: "Render data using LiquidJS"
}
];
export function SelectNode(props) {
const [selected, setSelected] = useState<string>();
const reactflow = useReactFlow();
const { actions } = useFlowCanvas();
const old_id = props.id;
async function handleMake() {
const node = nodes.find((n) => n.type === selected)!;
const label = "untitled";
await actions.task.create(label, node.template);
reactflow.setNodes((prev) =>
prev.map((n) => {
if (n.id === old_id) {
return {
...n,
id: "task-" + label,
type: "task",
data: {
...node.template,
label
}
};
}
return n;
})
);
setTimeout(() => {
reactflow.setEdges((prev) =>
prev.map((e) => {
console.log("edge?", e, old_id, e.target === old_id);
if (e.target === old_id) {
return {
...e,
id: "task-" + label,
target: "task-" + label
};
}
return e;
})
);
}, 100);
console.log("make", node);
}
//console.log("SelectNode", props);
return (
<DefaultNode className="w-96">
<Handle type="target" id="select-in" />
<DefaultNode.Header className="gap-3 justify-start py-2">
<div className="bg-primary/10 rounded-full w-4 h-4" />
<div className="bg-primary/5 rounded-full w-1/2 h-4" />
</DefaultNode.Header>
<DefaultNode.Content>
<div>select</div>
<div className="grid grid-cols-3 gap-2">
{nodes.map((node) => (
<button
type="button"
key={node.type}
className={twMerge(
"border border-primary/10 rounded-md py-2 px-4 hover:bg-primary/10",
selected === node.type && "bg-primary/10"
)}
onClick={() => setSelected(node.type)}
>
{node.label}
</button>
))}
</div>
<button onClick={handleMake}>make</button>
</DefaultNode.Content>
</DefaultNode>
);
}

View File

@@ -0,0 +1,9 @@
import { SelectNode } from "./SelectNode";
import { TaskNode } from "./tasks/TaskNode";
import { TriggerNode } from "./triggers/TriggerNode";
export const nodeTypes = {
select: SelectNode,
trigger: TriggerNode,
task: TaskNode
};

View File

@@ -0,0 +1,140 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, NativeSelect, Select, TextInput } from "@mantine/core";
import { useToggle } from "@mantine/hooks";
import { IconMinus, IconPlus, IconWorld } from "@tabler/icons-react";
import type { Node, NodeProps } from "@xyflow/react";
import type { Static } from "core/utils";
import { Type } from "core/utils";
import { FetchTask } from "flows";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "ui";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { SegmentedControl } from "ui/components/form/SegmentedControl";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { type TFlowNodeData, useFlowSelector } from "../../../hooks/use-flow";
import { KeyValueInput } from "../../form/KeyValueInput";
import { BaseNode } from "../BaseNode";
const schema = Type.Composite([
FetchTask.schema,
Type.Object({
query: Type.Optional(Type.Record(Type.String(), Type.String()))
})
]);
type TFetchTaskSchema = Static<typeof FetchTask.schema>;
type FetchTaskFormProps = NodeProps<Node<TFlowNodeData>> & {
params: TFetchTaskSchema;
onChange: (params: any) => void;
};
export function FetchTaskForm({ onChange, params, ...props }: FetchTaskFormProps) {
const [advanced, toggle] = useToggle([true, false]);
const [bodyType, setBodyType] = useState("None");
const {
register,
handleSubmit,
setValue,
getValues,
formState: { isValid, errors },
watch,
control
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: params as Static<typeof schema>,
mode: "onChange"
//defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>
});
function onSubmit(data) {
console.log("submit task", data);
onChange(data);
}
//console.log("FetchTaskForm", watch());
return (
<BaseNode
{...props}
isInvalid={!isValid}
className="w-[400px]"
Icon={IconWorld}
tabs={TaskNodeTabs({ watch })}
onChangeName={console.log}
>
<form onBlur={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-row gap-2 items-center">
<MantineSelect
className="w-36"
label="Method"
defaultValue="GET"
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
name="method"
control={control}
/>
<TextInput
className="w-full"
label="Mapping Path"
placeholder="/path/to-be/mapped"
classNames={{ wrapper: "font-mono pt-px" }}
{...register("url")}
/>
</div>
<Button
onClick={toggle as any}
className="justify-center"
size="small"
variant="ghost"
iconSize={14}
IconLeft={advanced ? IconMinus : IconPlus}
>
More options
</Button>
{advanced && (
<>
<KeyValueInput
label="URL query"
onChange={(items: any) => setValue("query", items)}
error={errors.query?.message}
/>
<KeyValueInput label="Headers" />
<div className="flex flex-row gap-2 items-center mt-2">
<Input.Wrapper className="w-full">
<div className="flex flex-row gap-2 items-center">
<Input.Label>Body</Input.Label>
<SegmentedControl
data={["None", "Form", "JSON", "Code"]}
size="xs"
defaultValue={bodyType}
onChange={(value) => setBodyType(value)}
/>
</div>
{bodyType === "Form" && <KeyValueInput label={undefined} />}
{bodyType === "JSON" && <KeyValueInput label={undefined} />}
</Input.Wrapper>
</div>
</>
)}
</form>
</BaseNode>
);
}
const TaskNodeTabs = ({ watch }: any) => [
{
id: "json",
label: "JSON",
content: () => (
<div className="scroll-auto">
<JsonViewer json={watch()} expand={2} className="bg-white break-all" />
</div>
)
},
{
id: "test",
label: "test",
content: () => <div>test</div>
}
];

View File

@@ -0,0 +1,13 @@
import { IconWorld } from "@tabler/icons-react";
import { LiquidJsEditor } from "ui/components/code/LiquidJsEditor";
import { BaseNode } from "../BaseNode";
export function RenderNode(props) {
return (
<BaseNode {...props} onChangeName={console.log} Icon={IconWorld} className="w-[400px]">
<form className="flex flex-col gap-3">
<LiquidJsEditor value={props.params.render} onChange={console.log} />
</form>
</BaseNode>
);
}

View File

@@ -0,0 +1,51 @@
import { TypeRegistry } from "@sinclair/typebox";
import { type Node, type NodeProps, Position } from "@xyflow/react";
import { registerCustomTypeboxKinds } from "core/utils";
import type { TAppFlowTaskSchema } from "flows/AppFlows";
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
import { Handle } from "../Handle";
import { FetchTaskForm } from "./FetchTaskNode";
import { RenderNode } from "./RenderNode";
registerCustomTypeboxKinds(TypeRegistry);
const TaskComponents = {
fetch: FetchTaskForm,
render: RenderNode
};
export const TaskNode = (
props: NodeProps<
Node<
TAppFlowTaskSchema & {
label: string;
last?: boolean;
start?: boolean;
responding?: boolean;
}
>
>
) => {
const {
data: { label, start, last, responding }
} = props;
const task = useFlowSelector((s) => s.flow!.tasks![label])!;
const { actions } = useFlowCanvas();
const Component =
task.type in TaskComponents ? TaskComponents[task.type] : () => <div>unsupported</div>;
function handleChange(params: any) {
//console.log("TaskNode:update", task.type, label, params);
actions.task.update(label, params);
}
return (
<>
<Component {...props} params={task.params as any} onChange={handleChange} />
<Handle type="target" id={`${label}-in`} />
<Handle type="source" id={`${label}-out`} />
</>
);
};

View File

@@ -0,0 +1,172 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput } from "@mantine/core";
import { TypeRegistry } from "@sinclair/typebox";
import { Clean } from "@sinclair/typebox/value";
import { type Node, type NodeProps, Position } from "@xyflow/react";
import {
Const,
type Static,
StringEnum,
Type,
registerCustomTypeboxKinds,
transformObject
} from "core/utils";
import { TriggerMap } from "flows";
import type { TAppFlowTriggerSchema } from "flows/AppFlows";
import { isEqual } from "lodash-es";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSegmentedControl } from "ui/components/form/hook-form-mantine/MantineSegmentedControl";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
import { BaseNode } from "../BaseNode";
import { Handle } from "../Handle";
// @todo: check if this could become an issue
registerCustomTypeboxKinds(TypeRegistry);
const schema = Type.Object({
trigger: Type.Union(
Object.values(
transformObject(TriggerMap, (trigger, name) =>
Type.Object(
{
type: Const(name),
config: trigger.cls.schema
},
{ title: String(name), additionalProperties: false }
)
)
)
)
});
export const TriggerNode = (props: NodeProps<Node<TAppFlowTriggerSchema & { label: string }>>) => {
const {
data: { label, ...trigger }
} = props;
//console.log("TriggerNode");
const state = useFlowSelector((s) => s.flow!.trigger!);
const { actions } = useFlowCanvas();
const {
register,
handleSubmit,
setValue,
getValues,
formState: { isValid, errors },
watch,
control
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: { trigger: state } as Static<typeof schema>,
mode: "onChange"
});
const data = watch("trigger");
async function onSubmit(data: Static<typeof schema>) {
console.log("submit", data.trigger);
// @ts-ignore
await actions.trigger.update(data.trigger);
}
async function onChangeName(name: string) {
console.log("change name", name);
await actions.flow.setName(name);
}
/*useEffect(() => {
console.log("trigger update", data);
actions.trigger.update(data);
}, [data]);*/
return (
<BaseNode {...props} tabs={TriggerNodeTabs({ data })} onChangeName={onChangeName}>
<form onBlur={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-row justify-between items-center">
<MantineSegmentedControl
label="Trigger Type"
defaultValue="manual"
data={[
{ label: "Manual", value: "manual" },
{ label: "HTTP", value: "http" },
{ label: "Event", value: "event", disabled: true }
]}
name="trigger.type"
control={control}
/>
<MantineSegmentedControl
label="Execution Mode"
defaultValue="async"
data={[
{ label: "Async", value: "async" },
{ label: "Sync", value: "sync" }
]}
name="trigger.config.mode"
control={control}
/>
</div>
{data.type === "manual" && <Manual />}
{data.type === "http" && (
<Http form={{ watch, register, setValue, getValues, control }} />
)}
</form>
<Handle type="source" id="trigger-out" />
</BaseNode>
);
};
const Manual = () => {
return null;
};
const Http = ({ form }) => {
return (
<>
<div className="flex flex-row gap-2 items-center">
<MantineSelect
className="w-36"
label="Method"
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
name="trigger.config.method"
control={form.control}
/>
<TextInput
className="w-full"
label="Mapping Path"
placeholder="/trigger_http"
classNames={{ wrapper: "font-mono pt-px" }}
{...form.register("trigger.config.path")}
/>
</div>
<div className="flex flex-row gap-2 items-center">
<MantineSegmentedControl
className="w-full"
label="Response Type"
defaultValue="json"
data={[
{ label: "JSON", value: "json" },
{ label: "HTML", value: "html" },
{ label: "Text", value: "text" }
]}
name="trigger.config.response_type"
control={form.control}
/>
</div>
</>
);
};
const TriggerNodeTabs = ({ data, ...props }) => [
{
id: "json",
label: "JSON",
content: () => <JsonViewer json={data} expand={2} className="" />
},
{
id: "test",
label: "test",
content: () => <div>test</div>
}
];

View File

@@ -0,0 +1,209 @@
import { MarkerType, type Node } from "@xyflow/react";
import type { TAppFlowSchema, TAppFlowTriggerSchema } from "flows/AppFlows";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { isEqual } from "lodash-es";
import type { ModuleSchemas } from "modules/ModuleManager";
import { createContext, useCallback, useContext, useEffect } from "react";
import { useBknd } from "ui/client";
export type TFlowNodeData = {
label: string;
type: string;
last?: boolean;
start?: boolean;
responding?: boolean;
};
export type FlowContextType = {
name?: string;
data?: TAppFlowSchema;
schema: ModuleSchemas["flows"]["properties"]["flows"];
actions: {
flow: {
setName: (name: string) => Promise<any>;
};
trigger: {
update: (trigger: TAppFlowTriggerSchema) => Promise<any>;
};
task: {
create: (type: string, defaults?: object) => Promise<any>;
update: (name: string, params: any) => Promise<any>;
};
};
};
export type TFlowState = {
dirty: boolean;
name?: string;
flow?: TAppFlowSchema;
};
export const flowStateAtom = atom<TFlowState>({
dirty: false,
name: undefined,
flow: undefined
});
const FlowCanvasContext = createContext<FlowContextType>(undefined!);
const DEFAULT_FLOW = { trigger: {}, tasks: {}, connections: {} };
export function FlowCanvasProvider({ children, name }: { children: any; name?: string }) {
//const [dirty, setDirty] = useState(false);
const setFlowState = useSetAtom(flowStateAtom);
const s = useBknd();
const data = name ? (s.config.flows.flows[name] as TAppFlowSchema) : undefined;
const schema = s.schema.flows.properties.flows;
useEffect(() => {
if (name) {
setFlowState({ dirty: false, name, flow: data });
}
}, [name]);
const actions = {
flow: {
setName: async (name: string) => {
console.log("set name", name);
setFlowState((state) => ({ ...state, name, dirty: true }));
}
},
trigger: {
update: async (trigger: TAppFlowTriggerSchema | any) => {
console.log("update trigger", trigger);
setFlowState((state) => {
const flow = state.flow || DEFAULT_FLOW;
return { ...state, dirty: true, flow: { ...flow, trigger } };
});
//return s.actions.patch("flows", `flows.flows.${name}`, { trigger });
}
},
task: {
create: async (name: string, defaults: object = {}) => {
console.log("create task", name, defaults);
setFlowState((state) => {
const flow = state.flow || (DEFAULT_FLOW as any);
const tasks = { ...flow.tasks, [name]: defaults };
return { ...state, dirty: true, flow: { ...flow, tasks } };
});
},
update: async (name: string, params: any) => {
console.log("update task", name, params);
setFlowState((state) => {
const flow = state.flow || (DEFAULT_FLOW as any);
const task = { ...state.flow?.tasks?.[name], params };
return {
...state,
dirty: true,
flow: { ...flow, tasks: { ...flow.tasks, [name]: task } }
};
});
//return s.actions.patch("flows", `flows.flows.${name}.tasks.${name}`, task);
}
}
};
return (
<FlowCanvasContext.Provider value={{ name, data, schema, actions }}>
{children}
</FlowCanvasContext.Provider>
);
}
export function useFlowCanvas() {
return useContext(FlowCanvasContext);
}
export function useFlowCanvasState() {
return useAtomValue(flowStateAtom);
}
export function useFlowSelector<Reduced = TFlowState>(
selector: (state: TFlowState) => Reduced,
equalityFn: (a: any, b: any) => boolean = isEqual
) {
const selected = selectAtom(flowStateAtom, useCallback(selector, []), equalityFn);
return useAtom(selected)[0];
}
export function flowToNodes(flow: TAppFlowSchema, name: string): Node<TFlowNodeData>[] {
const nodes: Node<TFlowNodeData>[] = [
{
id: "trigger",
data: { label: name, type: flow.trigger.type },
type: "trigger",
dragHandle: ".drag-handle",
position: { x: 0, y: 0 }
}
];
let i = 1;
const count = Object.keys(flow.tasks ?? {}).length;
for (const [name, task] of Object.entries(flow.tasks ?? {})) {
const last = i === count;
const start = i === 1;
const responding = last;
nodes.push({
id: `task-${name}`,
data: { label: name, type: task.type, last, start, responding },
type: "task",
dragHandle: ".drag-handle",
// @todo: this is currently static
position: { x: 450 * i + (i - 1) * 64, y: 0 }
});
i++;
}
/*nodes.push({
id: "select",
data: { label: "Select", type: "select" },
type: "select",
position: { x: 500 * i, y: 0 }
});*/
return nodes;
}
export function flowToEdges(flow: TAppFlowSchema) {
const tasks = Object.entries(flow.tasks ?? {});
if (tasks.length === 0) return [];
const edges =
tasks.length >= 1
? [
{
id: "trigger-task",
source: "trigger",
target: `task-${tasks[0]?.[0]}`,
//type: "smoothstep",
style: {
strokeWidth: 2
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 10,
height: 10
}
}
]
: [];
for (const [id, connection] of Object.entries(flow.connections ?? {})) {
edges.push({
id,
source: "task-" + connection.source,
target: "task-" + connection.target,
style: {
strokeWidth: 2
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 10,
height: 10
}
});
}
return edges;
}

View File

@@ -0,0 +1,6 @@
import { atom } from "jotai";
const flowStateAtom = atom({
dirty: false,
flow: undefined
});

View File

@@ -0,0 +1,89 @@
import type { Edge, Node } from "@xyflow/react";
import type { Flow } from "flows";
import { TriggerComponent } from "../components/TriggerComponent";
import { FetchTaskComponent } from "../components/tasks/FetchTaskComponent";
import { RenderTaskComponent } from "../components/tasks/RenderTaskComponent";
import { TaskComponent } from "../components/tasks/TaskComponent";
export function calculateTaskPositions(numTasks: number, offset: number): number[] {
if (numTasks === 1) {
return [0];
}
const positions: number[] = [];
const totalOffset = (numTasks - 1) * offset;
const startPosition = -totalOffset / 2;
for (let i = 0; i < numTasks; i++) {
positions.push(startPosition + i * offset);
}
return positions;
}
export function getFlowNodes(flow: Flow): Node[] {
const nodes: Node[] = [];
const spacing = { x: 200, y: 400 };
const spacePerLine = 26;
// add trigger
nodes.push({
id: "trigger",
type: "trigger",
position: { x: 0, y: 0 },
data: { trigger: flow.trigger },
dragHandle: ".drag-handle"
});
console.log("adding node", { id: "trigger" });
// @todo: doesn't include unconnected tasks
flow.getSequence().forEach((step, i) => {
const step_count = step.length;
const height = step.reduce((acc, task) => acc + task.name.length, 0) * spacePerLine;
step.forEach((task, j) => {
const xs = calculateTaskPositions(step_count, spacing.x);
const isRespondingTask = flow.respondingTask?.name === task.name;
const isStartTask = flow.startTask.name === task.name;
nodes.push({
id: task.name,
type: task.type,
position: { x: xs[j]!, y: (i + 1) * spacing.y },
data: { task, state: { i: 0, isRespondingTask, isStartTask, event: undefined } },
dragHandle: ".drag-handle"
});
});
});
return nodes;
}
export function getFlowEdges(flow: Flow): Edge[] {
const edges: Edge[] = [];
const startTask = flow.startTask;
const trigger = flow.trigger;
// add trigger connection
edges.push({
id: `trigger-${startTask.name}${new Date().getTime()}`,
source: "trigger",
target: startTask.name
//type: "",
});
// add task connections
flow.connections.forEach((c) => {
edges.push({
id: `${c.source.name}-${c.target.name}${new Date().getTime()}`,
source: c.source.name,
target: c.target.name
//type: "",
});
});
return edges;
}
export function getNodeTypes(flow: Flow) {
return {
trigger: TriggerComponent,
render: RenderTaskComponent,
log: TaskComponent,
fetch: TaskComponent
};
}

View File

@@ -0,0 +1,413 @@
import {
type ComponentPropsWithRef,
type RefObject,
memo,
useEffect,
useRef,
useState
} from "react";
import { TbDots } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { type FileWithPath, useDropzone } from "./use-dropzone";
export type FileState = {
body: FileWithPath | string;
path: string;
name: string;
size: number;
type: string;
state: "pending" | "uploading" | "uploaded" | "failed" | "initial" | "deleting";
progress: number;
};
export type DropzoneRenderProps = {
wrapperRef: RefObject<HTMLDivElement>;
inputProps: ComponentPropsWithRef<"input">;
state: {
files: FileState[];
isOver: boolean;
showPlaceholder: boolean;
};
actions: {
uploadFileProgress: (file: FileState) => Promise<void>;
deleteFile: (file: FileState) => Promise<void>;
openFileInput: () => void;
};
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload">;
};
export type DropzoneProps = {
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
handleDelete: (file: FileState) => Promise<boolean>;
initialItems?: FileState[];
maxItems?: number;
autoUpload?: boolean;
placeholder?: {
show?: boolean;
text?: string;
};
};
export function Dropzone({
getUploadInfo,
handleDelete,
initialItems = [],
maxItems,
autoUpload,
placeholder
}: DropzoneProps) {
const [files, setFiles] = useState<FileState[]>(initialItems);
const [uploading, setUploading] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => {
if (maxItems && files.length + newFiles.length > maxItems) {
alert("Max items reached");
return;
}
console.log("files", newFiles);
setFiles((prev) => {
const currentPaths = prev.map((f) => f.path);
const filteredFiles: FileState[] = newFiles
.filter((f) => f.path && !currentPaths.includes(f.path))
.map((f) => ({
body: f,
path: f.path!,
name: f.name,
size: f.size,
type: f.type,
state: "pending",
progress: 0
}));
return [...prev, ...filteredFiles];
});
if (autoUpload) {
setUploading(true);
}
},
onOver: (items) => {
if (maxItems && files.length + items.length >= maxItems) {
// indicate that the drop is not allowed
return;
}
}
/*onOver: (items) =>
console.log(
"onOver",
items,
items.map((i) => [i.kind, i.type].join(":"))
)*/
});
useEffect(() => {
console.log("files updated");
}, [files]);
useEffect(() => {
if (uploading) {
(async () => {
const pendingFiles = files.filter((f) => f.state === "pending");
if (pendingFiles.length === 0) {
setUploading(false);
return;
} else {
for (const file of pendingFiles) {
await uploadFileProgress(file);
}
}
})();
}
}, [uploading]);
function setFileState(path: string, state: FileState["state"], progress?: number) {
setFiles((prev) =>
prev.map((f) => {
//console.log("compare", f.path, path, f.path === path);
if (f.path === path) {
return {
...f,
state,
progress: progress ?? f.progress
};
}
return f;
})
);
}
function replaceFileState(prevPath: string, newState: Partial<FileState>) {
setFiles((prev) =>
prev.map((f) => {
if (f.path === prevPath) {
return {
...f,
...newState
};
}
return f;
})
);
}
function removeFileFromState(path: string) {
setFiles((prev) => prev.filter((f) => f.path !== path));
}
function uploadFileProgress(file: FileState) {
return new Promise<void>((resolve, reject) => {
if (!file.body) {
console.error("File has no body");
reject();
return;
} else if (file.state !== "pending") {
console.error("File is not pending");
reject();
return;
} else if (file.body instanceof File === false) {
console.error("File body is not a File instance");
reject();
return;
}
const { url, headers, method = "POST" } = getUploadInfo(file.body);
const formData = new FormData();
formData.append("file", file.body);
const xhr = new XMLHttpRequest();
xhr.open(method, url, true);
if (headers) {
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
}
// Handle progress events
xhr.upload.addEventListener("progress", (event) => {
console.log("progress", event.loaded, event.total);
if (event.lengthComputable) {
setFileState(file.path, "uploading", event.loaded / event.total);
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Progress: ${percentComplete.toFixed(2)}%`);
} else {
console.log(
"Unable to compute progress information since the total size is unknown"
);
}
});
xhr.onload = () => {
console.log("onload", file.path, xhr.status);
if (xhr.status === 200) {
//setFileState(file.path, "uploaded", 1);
console.log("Upload complete");
try {
const response = JSON.parse(xhr.responseText);
console.log("Response:", file, response);
console.log("New state", response.state);
replaceFileState(file.path, {
...response.state,
progress: 1,
state: "uploaded"
});
} catch (e) {
setFileState(file.path, "uploaded", 1);
console.error("Error parsing response", e);
}
resolve();
} else {
setFileState(file.path, "failed", 1);
console.error("Upload failed with status: ", xhr.status);
reject();
}
};
xhr.onerror = () => {
console.error("Error during the upload process.");
};
xhr.onloadstart = () => {
setFileState(file.path, "uploading", 0);
console.log("loadstart");
};
xhr.setRequestHeader("Accept", "application/json");
xhr.send(formData);
});
}
async function deleteFile(file: FileState) {
console.log("deleteFile", file);
switch (file.state) {
case "uploaded":
case "initial":
if (window.confirm("Are you sure you want to delete this file?")) {
console.log('setting state to "deleting"', file);
setFileState(file.path, "deleting");
await handleDelete(file);
removeFileFromState(file.path);
}
break;
}
}
const openFileInput = () => inputRef.current?.click();
const showPlaceholder = Boolean(
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
);
const Component = DropzoneInner;
return (
<Component
wrapperRef={ref}
inputProps={{
ref: inputRef,
type: "file",
multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange
}}
state={{ files, isOver, showPlaceholder }}
actions={{ uploadFileProgress, deleteFile, openFileInput }}
dropzoneProps={{ maxItems, placeholder, autoUpload }}
/>
);
}
const DropzoneInner = ({
wrapperRef,
inputProps,
state: { files, isOver, showPlaceholder },
actions: { uploadFileProgress, deleteFile, openFileInput },
dropzoneProps: { placeholder }
}: DropzoneRenderProps) => {
return (
<div
ref={wrapperRef}
/*data-drag-over={"1"}*/
data-drag-over={isOver ? "1" : undefined}
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none"
>
<div className="hidden">
<input
{...inputProps}
/*ref={inputRef}
type="file"
multiple={!maxItems || maxItems > 1}
onChange={handleFileInputChange}*/
/>
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
{files.map((file, i) => (
<Preview
key={file.path}
file={file}
handleUpload={uploadFileProgress}
handleDelete={deleteFile}
/>
))}
{showPlaceholder && (
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
)}
</div>
</div>
</div>
);
};
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
return (
<div
className="w-[49%] aspect-[1/0.9] md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
onClick={onClick}
>
<span className="">{text}</span>
</div>
);
};
const Wrapper = ({ file }: { file: FileState }) => {
if (file.type.startsWith("image/")) {
return <ImagePreview file={file} />;
}
if (file.type.startsWith("video/")) {
return <VideoPreview file={file} />;
}
return <FallbackPreview file={file} />;
};
const WrapperMemoized = memo(Wrapper, (prev, next) => prev.file.path === next.file.path);
type PreviewProps = {
file: FileState;
handleUpload: (file: FileState) => Promise<void>;
handleDelete: (file: FileState) => Promise<void>;
};
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => {
const dropdownItems = [
["initial", "uploaded"].includes(file.state) && {
label: "Delete",
onClick: () => handleDelete(file)
},
["initial", "pending"].includes(file.state) && {
label: "Upload",
onClick: () => handleUpload(file)
}
];
return (
<div
className={twMerge(
"w-[49%] md:w-60 flex flex-col border border-muted relative",
file.state === "deleting" && "opacity-70"
)}
>
{/*{file.state}*/}
<div className="absolute top-2 right-2">
<Dropdown items={dropdownItems} position="bottom-end">
<IconButton Icon={TbDots} />
</Dropdown>
</div>
{file.state === "uploading" && (
<div className="absolute w-full top-0 left-0 right-0 h-1">
<div
className="bg-blue-600 h-1 transition-all duration-75"
style={{ width: (file.progress * 100).toFixed(0) + "%" }}
/>
</div>
)}
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
<WrapperMemoized file={file} />
</div>
<div className="flex flex-col px-1.5 py-1">
<p className="truncate">{file.name}</p>
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
<span className="truncate">{file.type}</span>
<span>{(file.size / 1024).toFixed(1)} KB</span>
</div>
</div>
</div>
);
};
const ImagePreview = ({ file }: { file: FileState }) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <img className="max-w-full max-h-full" src={objectUrl} />;
};
const VideoPreview = ({ file }: { file: FileState }) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <video src={objectUrl} />;
};
const FallbackPreview = ({ file }: { file: FileState }) => {
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
};

View File

@@ -0,0 +1,262 @@
/**
* From https://github.com/react-dropzone/file-selector
* slightly adjusted
* MIT License (2020 Roland Groza)
*/
import { MIME_TYPES } from "media";
const FILES_TO_IGNORE = [
// Thumbnail cache files for macOS and Windows
".DS_Store", // macOs
"Thumbs.db" // Windows
];
export function toFileWithPath(file: FileWithPath, path?: string): FileWithPath {
const f = withMimeType(file);
if (typeof f.path !== "string") {
// on electron, path is already set to the absolute path
const { webkitRelativePath } = file;
Object.defineProperty(f, "path", {
value:
typeof path === "string"
? path
: // If <input webkitdirectory> is set,
// the File will have a {webkitRelativePath} property
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/webkitdirectory
typeof webkitRelativePath === "string" && webkitRelativePath.length > 0
? webkitRelativePath
: file.name,
writable: false,
configurable: false,
enumerable: true
});
}
return f;
}
export interface FileWithPath extends File {
readonly path?: string;
}
function withMimeType(file: FileWithPath) {
const { name } = file;
const hasExtension = name && name.lastIndexOf(".") !== -1;
console.log("withMimeType", name, hasExtension);
if (hasExtension && !file.type) {
const ext = name.split(".").pop()!.toLowerCase();
const type = MIME_TYPES.get(ext);
console.log("withMimeType:in", ext, type);
if (type) {
Object.defineProperty(file, "type", {
value: type,
writable: false,
configurable: false,
enumerable: true
});
}
}
return file;
}
export interface FileWithPath extends File {
readonly path?: string;
}
/**
* Convert a DragEvent's DataTrasfer object to a list of File objects
* NOTE: If some of the items are folders,
* everything will be flattened and placed in the same list but the paths will be kept as a {path} property.
*
* EXPERIMENTAL: A list of https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle objects can also be passed as an arg
* and a list of File objects will be returned.
*
* @param evt
*/
export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> {
if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) {
return getDataTransferFiles(evt.dataTransfer, evt.type);
// biome-ignore lint/style/noUselessElse: not useless
} else if (isChangeEvt(evt)) {
return getInputFiles(evt);
// biome-ignore lint/style/noUselessElse: not useless
} else if (
Array.isArray(evt) &&
evt.every((item) => "getFile" in item && typeof item.getFile === "function")
) {
return getFsHandleFiles(evt);
}
return [];
}
function isDataTransfer(value: any): value is DataTransfer {
return isObject(value);
}
function isChangeEvt(value: any): value is Event {
return isObject<Event>(value) && isObject(value.target);
}
function isObject<T>(v: any): v is T {
return typeof v === "object" && v !== null;
}
function getInputFiles(evt: Event) {
return fromList<FileWithPath>((evt.target as HTMLInputElement).files).map((file) =>
toFileWithPath(file)
);
}
// Ee expect each handle to be https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
async function getFsHandleFiles(handles: any[]) {
const files = await Promise.all(handles.map((h) => h.getFile()));
return files.map((file) => toFileWithPath(file));
}
async function getDataTransferFiles(dt: DataTransfer, type: string) {
// IE11 does not support dataTransfer.items
// See https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items#Browser_compatibility
if (dt.items) {
const items = fromList<DataTransferItem>(dt.items).filter((item) => item.kind === "file");
// According to https://html.spec.whatwg.org/multipage/dnd.html#dndevents,
// only 'dragstart' and 'drop' has access to the data (source node)
if (type !== "drop") {
return items;
}
const files = await Promise.all(items.map(toFilePromises));
return noIgnoredFiles(flatten<FileWithPath>(files));
}
return noIgnoredFiles(fromList<FileWithPath>(dt.files).map((file) => toFileWithPath(file)));
}
function noIgnoredFiles(files: FileWithPath[]) {
return files.filter((file) => FILES_TO_IGNORE.indexOf(file.name) === -1);
}
// IE11 does not support Array.from()
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#Browser_compatibility
// https://developer.mozilla.org/en-US/docs/Web/API/FileList
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItemList
function fromList<T>(items: DataTransferItemList | FileList | null): T[] {
if (items === null) {
return [];
}
const files: any[] = [];
// tslint:disable: prefer-for-of
for (let i = 0; i < items.length; i++) {
const file = items[i];
files.push(file);
}
return files;
}
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem
function toFilePromises(item: DataTransferItem) {
if (typeof item.webkitGetAsEntry !== "function") {
return fromDataTransferItem(item);
}
const entry = item.webkitGetAsEntry();
// Safari supports dropping an image node from a different window and can be retrieved using
// the DataTransferItem.getAsFile() API
// NOTE: FileSystemEntry.file() throws if trying to get the file
if (entry?.isDirectory) {
return fromDirEntry(entry) as any;
}
return fromDataTransferItem(item);
}
function flatten<T>(items: any[]): T[] {
return items.reduce(
(acc, files) => [
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
...acc,
...(Array.isArray(files) ? flatten(files) : [files])
],
[]
);
}
function fromDataTransferItem(item: DataTransferItem) {
const file = item.getAsFile();
if (!file) {
return Promise.reject(`${item} is not a File`);
}
const fwp = toFileWithPath(file);
return Promise.resolve(fwp);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemEntry
async function fromEntry(entry: any) {
return entry.isDirectory ? fromDirEntry(entry) : fromFileEntry(entry);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry
function fromDirEntry(entry: any) {
const reader = entry.createReader();
return new Promise<FileArray[]>((resolve, reject) => {
const entries: Promise<FileValue[]>[] = [];
function readEntries() {
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry/createReader
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
reader.readEntries(
async (batch: any[]) => {
if (!batch.length) {
// Done reading directory
try {
const files = await Promise.all(entries);
resolve(files);
} catch (err) {
reject(err);
}
} else {
const items = Promise.all(batch.map(fromEntry));
entries.push(items);
// Continue reading
readEntries();
}
},
(err: any) => {
reject(err);
}
);
}
readEntries();
});
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileEntry
async function fromFileEntry(entry: any) {
return new Promise<FileWithPath>((resolve, reject) => {
entry.file(
(file: FileWithPath) => {
const fwp = toFileWithPath(file, entry.fullPath);
resolve(fwp);
},
(err: any) => {
reject(err);
}
);
});
}
// Infinite type recursion
// https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
interface FileArray extends Array<FileValue> {}
type FileValue = FileWithPath | FileArray[];

View File

@@ -0,0 +1,79 @@
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { type FileWithPath, fromEvent } from "./file-selector";
type DropzoneProps = {
onDropped: (files: FileWithPath[]) => void;
onOver?: (items: DataTransferItem[]) => void;
};
const events = {
enter: ["dragenter", "dragover", "dragstart"],
leave: ["dragleave", "drop"],
};
const allEvents = [...events.enter, ...events.leave];
export function useDropzone({ onDropped, onOver }: DropzoneProps) {
const [isOver, setIsOver] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const onOverCalled = useRef(false);
// Prevent default behavior (Prevent file from being opened)
const preventDefaults = useCallback((e: Event) => {
e.preventDefault();
e.stopPropagation();
}, []);
const toggleHighlight = useCallback(async (e: Event) => {
const _isOver = events.enter.includes(e.type);
if (onOver && _isOver !== isOver && !onOverCalled.current) {
onOver((await fromEvent(e)) as DataTransferItem[]);
onOverCalled.current = true;
}
setIsOver(_isOver);
if (_isOver === false && onOverCalled.current) {
onOverCalled.current = false;
}
}, []);
const handleDrop = useCallback(
async (e: DragEvent) => {
const files = await fromEvent(e);
onDropped?.(files as any);
onOverCalled.current = false;
},
[onDropped],
);
const handleFileInputChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const files = await fromEvent(e);
onDropped?.(files as any);
},
[onDropped],
);
useEffect(() => {
const el: HTMLDivElement = ref.current!;
allEvents.forEach((eventName) => {
el.addEventListener(eventName, preventDefaults);
el.addEventListener(eventName, toggleHighlight);
});
// Handle dropped files
el.addEventListener("drop", handleDrop);
return () => {
allEvents.forEach((eventName) => {
el.removeEventListener(eventName, preventDefaults);
el.removeEventListener(eventName, toggleHighlight);
});
el.removeEventListener("drop", handleDrop);
};
}, []);
return { ref, isOver, fromEvent, onDropped, handleFileInputChange };
}
export type { FileWithPath };

View File

@@ -0,0 +1,31 @@
import type { MediaFieldSchema } from "media/AppMedia";
import type { FileState } from "./components/dropzone/Dropzone";
export function mediaItemToFileState(
item: MediaFieldSchema,
options: {
overrides?: Partial<FileState>;
baseUrl?: string;
} = { overrides: {}, baseUrl: "" }
): FileState {
return {
body: `${options.baseUrl}/api/media/file/${item.path}`,
path: item.path,
name: item.path,
size: item.size ?? 0,
type: item.mime_type ?? "",
state: "uploaded",
progress: 0,
...options.overrides
};
}
export function mediaItemsToFileStates(
items: MediaFieldSchema[],
options: {
overrides?: Partial<FileState>;
baseUrl?: string;
} = { overrides: {}, baseUrl: "" }
): FileState[] {
return items.map((item) => mediaItemToFileState(item, options));
}