mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
117
app/src/ui/modules/auth/LoginForm.tsx
Normal file
117
app/src/ui/modules/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type { FieldApi, FormApi } from "@tanstack/react-form";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
EnumField,
|
||||
type Field,
|
||||
JsonField,
|
||||
JsonSchemaField,
|
||||
RelationField
|
||||
} from "data";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { useClient } from "ui/client";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "../../media/helper";
|
||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
Form: FormApi<any>;
|
||||
className?: string;
|
||||
action: "create" | "update";
|
||||
};
|
||||
|
||||
export function EntityForm({
|
||||
entity,
|
||||
entityId,
|
||||
handleSubmit,
|
||||
fieldsDisabled,
|
||||
Form,
|
||||
data,
|
||||
className,
|
||||
action
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
console.log("data", { data, fields });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Subscribe
|
||||
selector={(state) => {
|
||||
//console.log("state", state);
|
||||
return [state.canSubmit, state.isValid, state.errors];
|
||||
}}
|
||||
children={([canSubmit, isValid, errors]) => {
|
||||
//console.log("form:state", { canSubmit, isValid, errors });
|
||||
return (
|
||||
!isValid && (
|
||||
<div className="flex flex-col dark:bg-red-950 bg-red-100 p-4">
|
||||
<p>Form is invalid.</p>
|
||||
{Array.isArray(errors) && (
|
||||
<ul className="list-disc">
|
||||
{errors.map((error, key) => (
|
||||
<li className="ml-6" key={key}>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={className}>
|
||||
{fields.map((field, key) => {
|
||||
// @todo: tanstack form re-uses the state, causes issues navigating between entities w/ same fields
|
||||
|
||||
// media field needs to render outside of the form
|
||||
// as its value is not stored in the form state
|
||||
if (field instanceof MediaField) {
|
||||
return (
|
||||
<EntityMediaFormField
|
||||
key={field.name + key}
|
||||
entity={entity}
|
||||
entityId={entityId}
|
||||
formApi={Form}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.isFillable(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _key = `${entity.name}-${field.name}-${key}`;
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
key={_key}
|
||||
name={field.name}
|
||||
children={(props) => (
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="hidden">
|
||||
<button type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type EntityFormFieldProps<
|
||||
T extends keyof JSX.IntrinsicElements = "input",
|
||||
F extends Field = Field
|
||||
> = ComponentProps<T> & {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: F;
|
||||
action: "create" | "update";
|
||||
data?: EntityData;
|
||||
};
|
||||
|
||||
type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
|
||||
if (typeof e === "object" && "target" in e) {
|
||||
console.log("handleUpdate", e.target.value);
|
||||
fieldApi.handleChange(e.target.value);
|
||||
} else {
|
||||
console.log("handleUpdate-", e);
|
||||
fieldApi.handleChange(e);
|
||||
}
|
||||
});
|
||||
|
||||
//const required = field.isRequired();
|
||||
//const customFieldProps = { ...props, action, required };
|
||||
|
||||
if (field instanceof RelationField) {
|
||||
return (
|
||||
<EntityRelationalFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof JsonField) {
|
||||
return <EntityJsonFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
if (field instanceof JsonSchemaField) {
|
||||
return (
|
||||
<EntityJsonSchemaFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof EnumField) {
|
||||
return <EntityEnumFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
const fieldElement = field.getHtmlConfig().element;
|
||||
const fieldProps = field.getHtmlConfig().props as any;
|
||||
const Element = Formy.formElementFactory(fieldElement ?? "input", fieldProps);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Element
|
||||
{...fieldProps}
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityMediaFormField({
|
||||
formApi,
|
||||
field,
|
||||
entity,
|
||||
entityId,
|
||||
disabled
|
||||
}: {
|
||||
formApi: FormApi<any>;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
const client = useClient();
|
||||
const value = formApi.useStore((state) => {
|
||||
const val = state.values[field.name];
|
||||
if (!val || typeof val === "undefined") return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [val];
|
||||
});
|
||||
|
||||
const initialItems: FileState[] =
|
||||
value.length === 0
|
||||
? []
|
||||
: mediaItemsToFileStates(value, {
|
||||
baseUrl: client.baseUrl,
|
||||
overrides: { state: "uploaded" }
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent(() => {
|
||||
const api = client.media().api();
|
||||
return {
|
||||
url: api.getEntityUploadUrl(entity.name, entityId, field.name),
|
||||
headers: api.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file) => {
|
||||
client.__invalidate(entity.name, entityId);
|
||||
return await client.media().deleteFile(file);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel field={field} />
|
||||
<Dropzone
|
||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
initialItems={initialItems}
|
||||
maxItems={field.getMaxItems()}
|
||||
autoUpload
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityJsonFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: JsonField }) {
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
minHeight="100"
|
||||
/*required={field.isRequired()}*/
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
{/*<Formy.Textarea
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityEnumFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: EnumField }) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
fieldApi.handleChange(e.target.value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.Select
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate as any}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
>
|
||||
{!field.isRequired() && <option value="">- Select -</option>}
|
||||
{field.getOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Formy.Select>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import type { Entity, EntityData } from "data";
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbSelector,
|
||||
TbSquare,
|
||||
TbSquareCheckFilled
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
|
||||
export const Check = () => {
|
||||
const [checked, toggle] = useToggle([false, true]);
|
||||
const Icon = checked ? TbSquareCheckFilled : TbSquare;
|
||||
return (
|
||||
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type TableProps = {
|
||||
data: EntityData[];
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: EntityData) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
perPageOptions?: number[];
|
||||
sort?: { by?: string; dir?: "asc" | "desc" };
|
||||
onClickSort?: (name: string) => void;
|
||||
onClickPerPage?: (perPage: number) => void;
|
||||
classNames?: {
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const EntityTable: React.FC<TableProps> = ({
|
||||
data = [],
|
||||
entity,
|
||||
select,
|
||||
checkable,
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
perPageOptions,
|
||||
onClickPerPage,
|
||||
classNames
|
||||
}) => {
|
||||
select = select && select.length > 0 ? select : entity.getSelect();
|
||||
total = total || data.length;
|
||||
page = page || 1;
|
||||
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function handleSortClick(name: string) {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-y-scroll">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-background">
|
||||
<tr>
|
||||
{checkable && (
|
||||
<th align="center" className="w-[40px]">
|
||||
<Check />
|
||||
</th>
|
||||
)}
|
||||
{select.map((property, key) => {
|
||||
const field = getField(property)!;
|
||||
|
||||
return (
|
||||
<th key={key}>
|
||||
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
|
||||
<button
|
||||
type="button"
|
||||
className="link hover:bg-primary/5 pl-2.5 pr-1 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1"
|
||||
onClick={() => onClickSort?.(field.name)}
|
||||
>
|
||||
<span className="text-left text-nowrap whitespace-nowrap">
|
||||
{field.getLabel()}
|
||||
</span>
|
||||
<SortIndicator sort={sort} field={field.name} />
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, key) => {
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
data-border={key > 0}
|
||||
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
|
||||
onClick={() => onClickRow?.(row)}
|
||||
>
|
||||
{checkable && (
|
||||
<td align="center">
|
||||
<Check />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{Object.entries(row).map(([key, value], index) => {
|
||||
const field = getField(key);
|
||||
const _value = field?.getValue(value, "table");
|
||||
return (
|
||||
<td key={index}>
|
||||
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
|
||||
{value !== null && typeof value !== "undefined" ? (
|
||||
<span
|
||||
className={twMerge(classNames?.value, "line-clamp-2")}
|
||||
>
|
||||
{_value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-10 font-mono">null</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
|
||||
Per Page{" "}
|
||||
<Dropdown
|
||||
items={perPageOptions.map((perPage) => ({
|
||||
label: String(perPage),
|
||||
perPage
|
||||
}))}
|
||||
position="top-end"
|
||||
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
|
||||
>
|
||||
<Button>{perPage}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<TableProps, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
|
||||
return <TbArrowDown size={18} className="mt-[1px]" />;
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total }
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
};
|
||||
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Entity, EntityData } from "data";
|
||||
import { CellValue, DataTable, type DataTableProps } from "ui/components/table/DataTable";
|
||||
|
||||
type EntityTableProps<Data extends EntityData = EntityData> = Omit<
|
||||
DataTableProps<Data>,
|
||||
"columns"
|
||||
> & {
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
};
|
||||
|
||||
export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
|
||||
const columns = select ?? entity.getSelect();
|
||||
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function renderHeader(column: string) {
|
||||
try {
|
||||
const field = getField(column)!;
|
||||
return field.getLabel();
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render header", { entity, select, ...props }, e);
|
||||
return column;
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue({ value, property }) {
|
||||
let _value: any = value;
|
||||
try {
|
||||
const field = getField(property)!;
|
||||
_value = field.getValue(value, "table");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
|
||||
}
|
||||
|
||||
return <CellValue value={_value} property={property} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
{...props}
|
||||
columns={columns}
|
||||
renderHeader={renderHeader}
|
||||
renderValue={renderValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
114
app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx
Normal file
114
app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { layoutWithDagre } from "ui/components/canvas/layouts";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { EntityTableNode } from "./EntityTableNode";
|
||||
|
||||
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
|
||||
return Object.entries(entities ?? {}).map(([name, entity]) => {
|
||||
return {
|
||||
id: name,
|
||||
data: { label: name, ...entity },
|
||||
type: "entity",
|
||||
dragHandle: ".drag-handle",
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function relationsToEdges(relations: AppDataConfig["relations"]) {
|
||||
return Object.entries(relations ?? {}).map(([name, relation]) => {
|
||||
let sourceHandle = relation.source + `:${relation.target}`;
|
||||
if (relation.config?.mappedBy) {
|
||||
sourceHandle = `${relation.source}:${relation.config?.mappedBy}`;
|
||||
}
|
||||
if (relation.type !== "poly") {
|
||||
sourceHandle += "_id";
|
||||
}
|
||||
|
||||
return {
|
||||
id: name,
|
||||
source: relation.source,
|
||||
target: relation.target,
|
||||
sourceHandle,
|
||||
targetHandle: relation.target + ":id"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
entity: EntityTableNode.Component
|
||||
} as const;
|
||||
|
||||
export function DataSchemaCanvas() {
|
||||
const {
|
||||
config: { data }
|
||||
} = useBknd();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
const nodes = entitiesToNodes(data.entities);
|
||||
const edges = relationsToEdges(data.relations).map((e) => ({
|
||||
...e,
|
||||
style: {
|
||||
stroke: theme === "light" ? "#ccc" : "#666"
|
||||
},
|
||||
type: "smoothstep",
|
||||
markerEnd: {
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777"
|
||||
}
|
||||
}));
|
||||
|
||||
const nodeLayout = layoutWithDagre({
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
...EntityTableNode.getSize(n)
|
||||
})),
|
||||
edges,
|
||||
graph: {
|
||||
rankdir: "LR",
|
||||
//align: "UR",
|
||||
ranker: "network-simplex",
|
||||
nodesep: 350,
|
||||
ranksep: 50
|
||||
}
|
||||
});
|
||||
|
||||
nodeLayout.nodes.forEach((node) => {
|
||||
const n = nodes.find((n) => n.id === node.id);
|
||||
if (n) {
|
||||
n.position = { x: node.x, y: node.y };
|
||||
}
|
||||
});
|
||||
|
||||
/*const _edges = edges.map((e) => ({
|
||||
...e,
|
||||
source: e.source + `-${e.target}_id`,
|
||||
target: e.target + "-id"
|
||||
}));*/
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<Canvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
fitViewOptions={{
|
||||
minZoom: 0.1,
|
||||
maxZoom: 0.8
|
||||
}}
|
||||
>
|
||||
<Panels zoom minimap />
|
||||
</Canvas>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
124
app/src/ui/modules/data/components/canvas/EntityTableNode.tsx
Normal file
124
app/src/ui/modules/data/components/canvas/EntityTableNode.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
|
||||
import type { TAppDataEntity } from "data/data-schema";
|
||||
import { useState } from "react";
|
||||
import { TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
|
||||
export type TableProps = {
|
||||
name: string;
|
||||
type?: string;
|
||||
fields: TableField[];
|
||||
};
|
||||
export type TableField = {
|
||||
name: string;
|
||||
type: string;
|
||||
primary?: boolean;
|
||||
foreign?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { data } = props;
|
||||
const fields = props.data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
//const flow = useReactFlow();
|
||||
//const flow = useTestContext();
|
||||
|
||||
return (
|
||||
<DefaultNode selected={props.selected}>
|
||||
<DefaultNode.Header label={data.label} />
|
||||
<div>
|
||||
{Object.entries(fields).map(([name, field], index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
field={{ name, ...field }}
|
||||
table={data.label}
|
||||
index={index}
|
||||
last={field_count === index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DefaultNode>
|
||||
);
|
||||
}
|
||||
|
||||
const handleStyle = {
|
||||
background: "transparent",
|
||||
border: "none"
|
||||
};
|
||||
const TableRow = ({
|
||||
field,
|
||||
table,
|
||||
index,
|
||||
onHover,
|
||||
last
|
||||
}: {
|
||||
field: TableField;
|
||||
table: string;
|
||||
index: number;
|
||||
last?: boolean;
|
||||
onHover?: (hovered: boolean) => void;
|
||||
}) => {
|
||||
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
|
||||
const handles = true;
|
||||
const handleId = `${table}:${field.name}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto",
|
||||
last && "rounded-bl-lg rounded-br-lg",
|
||||
"hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{handles && (
|
||||
<Handle
|
||||
title={handleId}
|
||||
type="source"
|
||||
id={handleId}
|
||||
position={Position.Left}
|
||||
style={{ top: handleTop, left: 0, ...handleStyle }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-6 pr-1.5 justify-center items-center">
|
||||
{field.type === "primary" && <TbKey className="text-yellow-700" />}
|
||||
{field.type === "relation" && <TbDiamonds className="text-sky-700" />}
|
||||
</div>
|
||||
<div className="flex flex-grow">{field.name}</div>
|
||||
<div className="flex opacity-60">{field.type}</div>
|
||||
|
||||
{handles && (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
title={handleId}
|
||||
id={handleId}
|
||||
position={Position.Right}
|
||||
style={{ top: handleTop, right: -5, ...handleStyle }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HEIGHTS = {
|
||||
header: 30,
|
||||
row: 32.5
|
||||
};
|
||||
|
||||
export const EntityTableNode = {
|
||||
Component: NodeComponent,
|
||||
getSize: (data: TAppDataEntity) => {
|
||||
const fields = data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
return {
|
||||
width: 320,
|
||||
height: HEIGHTS.header + HEIGHTS.row * field_count
|
||||
};
|
||||
}
|
||||
};
|
||||
80
app/src/ui/modules/data/components/fields-specs.ts
Normal file
80
app/src/ui/modules/data/components/fields-specs.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
TbBraces,
|
||||
TbCalendar,
|
||||
TbCirclesRelation,
|
||||
TbCodePlus,
|
||||
TbNumber123,
|
||||
TbPhoto,
|
||||
TbSelector,
|
||||
TbTextCaption,
|
||||
TbToggleLeft
|
||||
} from "react-icons/tb";
|
||||
|
||||
type TFieldSpec = {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
addable?: boolean;
|
||||
disabled?: string[];
|
||||
hidden?: string[];
|
||||
};
|
||||
|
||||
export const fieldSpecs: TFieldSpec[] = [
|
||||
{
|
||||
type: "primary",
|
||||
label: "Primary",
|
||||
icon: TbTextCaption,
|
||||
addable: false,
|
||||
disabled: ["name"],
|
||||
hidden: ["virtual"]
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Text",
|
||||
icon: TbTextCaption
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
label: "Number",
|
||||
icon: TbNumber123
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
label: "Boolean",
|
||||
icon: TbToggleLeft
|
||||
},
|
||||
{
|
||||
type: "date",
|
||||
label: "Date",
|
||||
icon: TbCalendar
|
||||
},
|
||||
{
|
||||
type: "enum",
|
||||
label: "Enum",
|
||||
icon: TbSelector
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
label: "JSON",
|
||||
icon: TbBraces
|
||||
},
|
||||
{
|
||||
type: "jsonschema",
|
||||
label: "JSON Schema",
|
||||
icon: TbCodePlus
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
label: "Relation",
|
||||
icon: TbCirclesRelation,
|
||||
addable: false,
|
||||
hidden: ["virtual"]
|
||||
},
|
||||
{
|
||||
type: "media",
|
||||
label: "Media",
|
||||
icon: TbPhoto,
|
||||
addable: false,
|
||||
hidden: ["virtual"]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import type { EntityData, JsonSchemaField } from "data";
|
||||
import { Suspense, lazy } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
|
||||
export function EntityJsonSchemaFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
data,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: JsonSchemaField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
}) {
|
||||
function handleChange(value: any) {
|
||||
if (disabled) return;
|
||||
fieldApi.setValue(value);
|
||||
}
|
||||
|
||||
const formData = data?.[field.name];
|
||||
//console.log("formData", { disabled, formData });
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<div
|
||||
data-disabled={disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<JsonSchemaForm
|
||||
schema={field.getJsonSchema()}
|
||||
onChange={handleChange}
|
||||
direction="horizontal"
|
||||
formData={formData}
|
||||
uiSchema={{
|
||||
"ui:globalOptions": { flexDirection: "row" },
|
||||
...field.getJsonUiSchema()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getHotkeyHandler, useHotkeys } from "@mantine/hooks";
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { EntityData, RelationField } from "data";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import { Button } from "ui";
|
||||
import { useBknd, useClient } from "ui/client";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { useEntities } from "ui/container";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { EntityTable } from "../EntityTable";
|
||||
|
||||
// @todo: allow clear if not required
|
||||
export function EntityRelationalFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
data,
|
||||
disabled,
|
||||
tabIndex
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: RelationField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
}) {
|
||||
const { app } = useBknd();
|
||||
const entity = app.entity(field.target())!;
|
||||
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
|
||||
const [location, navigate] = useLocation();
|
||||
const ref = useRef<any>(null);
|
||||
const client = useClient();
|
||||
const container = useEntities(
|
||||
field.target(),
|
||||
{
|
||||
limit: query.limit,
|
||||
offset: (query.page - 1) * query.limit
|
||||
//select: entity.getSelect(undefined, "form")
|
||||
},
|
||||
{ enabled: true }
|
||||
);
|
||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||
|
||||
const referenceField = data?.[field.reference()];
|
||||
const relationalField = data?.[field.name];
|
||||
|
||||
useEffect(() => {
|
||||
_setValue(data?.[field.reference()]);
|
||||
}, [referenceField]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const rel_value = field.target();
|
||||
if (!rel_value || !relationalField) return;
|
||||
|
||||
console.log("-- need to fetch", field.target(), relationalField);
|
||||
const fetched = await client.api.data.readOne(field.target(), relationalField);
|
||||
if (fetched.res.ok && fetched.data) {
|
||||
_setValue(fetched.data as any);
|
||||
}
|
||||
console.log("-- fetched", fetched);
|
||||
|
||||
console.log("relation", {
|
||||
referenceField,
|
||||
relationalField,
|
||||
data,
|
||||
field,
|
||||
entity
|
||||
});
|
||||
})();
|
||||
}, [relationalField]);
|
||||
|
||||
/*const initialValue: { id: number | undefined; [key: string]: any } = data?.[
|
||||
field.reference()
|
||||
] ?? {
|
||||
id: data?.[field.name],
|
||||
};*/
|
||||
|
||||
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("yo");
|
||||
if (_value) {
|
||||
navigate(routes.data.entity.edit(entity.name, _value.id as any));
|
||||
}
|
||||
}
|
||||
|
||||
/*console.log(
|
||||
"relationfield:data",
|
||||
{ _value, initialValue },
|
||||
data,
|
||||
field.reference(),
|
||||
entity,
|
||||
//container.entity,
|
||||
//data[field.reference()],
|
||||
data?.[field.name],
|
||||
field,
|
||||
);*/
|
||||
|
||||
// fix missing value on fields that are required
|
||||
useEffect(() => {
|
||||
if (field.isRequired() && !fieldApi.state.value) {
|
||||
fieldApi.setValue(container.data?.[0]?.id);
|
||||
}
|
||||
}, [container.data]);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<div
|
||||
data-disabled={!Array.isArray(container.data) || disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<Popover
|
||||
backdrop
|
||||
className=""
|
||||
target={({ toggle }) => (
|
||||
<PopoverTable
|
||||
container={container}
|
||||
entity={entity}
|
||||
query={query}
|
||||
toggle={toggle}
|
||||
onClickRow={(row) => {
|
||||
fieldApi.setValue(row.id);
|
||||
_setValue(row as any);
|
||||
toggle();
|
||||
}}
|
||||
onClickPage={(page) => {
|
||||
console.log("setting page", page);
|
||||
setQuery((prev) => ({ ...prev, page }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-muted/40 w-full h-11 focus:bg-muted rounded-md items-center px-2.5 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 cursor-pointer active:bg-muted/80 hover:bg-muted/60 flex flex-row gap-2"
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
ref.current?.click();
|
||||
}
|
||||
]
|
||||
])}
|
||||
>
|
||||
{_value ? (
|
||||
<>
|
||||
<div className="flex bg-primary/10 px-2 leading-none py-1.5 rounded-lg font-mono text-sm">
|
||||
{ucFirst(entity.name)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 overflow-clip w-1 flex-grow">
|
||||
{_value &&
|
||||
Object.entries(_value).map(([key, value]) => {
|
||||
const field = entity.getField(key)!;
|
||||
if (field.isHidden("table")) return null;
|
||||
|
||||
const _value = field.getValue(value, "table");
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-row gap-1 items-center flex-nowrap"
|
||||
>
|
||||
<span className="opacity-60 text-nowrap">
|
||||
{field.getLabel()}:
|
||||
</span>{" "}
|
||||
{_value !== null && typeof value !== "undefined" ? (
|
||||
<span className="text-nowrap truncate">{_value}</span>
|
||||
) : (
|
||||
<span className="opacity-30 text-nowrap font-mono mt-0.5">
|
||||
null
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button IconLeft={TbEye} onClick={handleViewItem} size="small">
|
||||
View
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="pl-2">- Select -</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<Formy.Input
|
||||
type="hidden"
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value ?? ""}
|
||||
onChange={console.log}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{/*<Formy.Select
|
||||
ref={ref}
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
data-value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
disabled={!Array.isArray(container.data)}
|
||||
>
|
||||
{container.data ? (
|
||||
<>
|
||||
{emptyOption}
|
||||
{!field.isRequired() && emptyOption}
|
||||
{container.data?.map(renderRow)}
|
||||
</>
|
||||
) : (
|
||||
<option value={undefined} disabled>
|
||||
Loading...
|
||||
</option>
|
||||
)}
|
||||
</Formy.Select>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }) => {
|
||||
function handleNext() {
|
||||
if (query.limit * query.page < container.meta?.count) {
|
||||
onClickPage(query.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (query.page > 1) {
|
||||
onClickPage(query.page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys([
|
||||
["ArrowRight", handleNext],
|
||||
["ArrowLeft", handlePrev],
|
||||
["Escape", toggle]
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityTable
|
||||
classNames={{ value: "line-clamp-1 truncate max-w-52 text-nowrap" }}
|
||||
data={container.data ?? []}
|
||||
entity={entity}
|
||||
total={container.meta?.count}
|
||||
page={query.page}
|
||||
onClickRow={onClickRow}
|
||||
onClickPage={onClickPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { type Static, StringEnum, StringIdentifier, Type, transformObject } from "core/utils";
|
||||
import { FieldClassMap } from "data";
|
||||
import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema";
|
||||
import { omit } from "lodash-es";
|
||||
import { forwardRef, useState } from "react";
|
||||
import {
|
||||
Modal2,
|
||||
type Modal2Ref,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalTitle
|
||||
} from "ui/components/modal/Modal2";
|
||||
import { Step, Steps, useStepContext } from "ui/components/steps/Steps";
|
||||
import { StepCreate } from "ui/modules/data/components/schema/create-modal/step.create";
|
||||
import { StepEntity } from "./step.entity";
|
||||
import { StepEntityFields } from "./step.entity.fields";
|
||||
import { StepRelation } from "./step.relation";
|
||||
import { StepSelect } from "./step.select";
|
||||
import Templates from "./templates/register";
|
||||
|
||||
export type CreateModalRef = Modal2Ref;
|
||||
|
||||
export const ModalActions = ["entity", "relation", "media"] as const;
|
||||
|
||||
export const entitySchema = Type.Composite([
|
||||
Type.Object({
|
||||
name: StringIdentifier
|
||||
}),
|
||||
entitiesSchema
|
||||
]);
|
||||
|
||||
const schemaAction = Type.Union([
|
||||
StringEnum(["entity", "relation", "media"]),
|
||||
Type.String({ pattern: "^template-" })
|
||||
]);
|
||||
export type TSchemaAction = Static<typeof schemaAction>;
|
||||
|
||||
const createFieldSchema = Type.Object({
|
||||
entity: StringIdentifier,
|
||||
name: StringIdentifier,
|
||||
field: Type.Array(fieldsSchema)
|
||||
});
|
||||
export type TFieldCreate = Static<typeof createFieldSchema>;
|
||||
|
||||
const createModalSchema = Type.Object(
|
||||
{
|
||||
action: schemaAction,
|
||||
entities: Type.Optional(
|
||||
Type.Object({
|
||||
create: Type.Optional(Type.Array(entitySchema))
|
||||
})
|
||||
),
|
||||
relations: Type.Optional(
|
||||
Type.Object({
|
||||
create: Type.Optional(Type.Array(Type.Union(relationsSchema)))
|
||||
})
|
||||
),
|
||||
fields: Type.Optional(
|
||||
Type.Object({
|
||||
create: Type.Optional(Type.Array(createFieldSchema))
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
export type TCreateModalSchema = Static<typeof createModalSchema>;
|
||||
|
||||
export const CreateModal = forwardRef<CreateModalRef>(function CreateModal(props, ref) {
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
|
||||
function close() {
|
||||
// @ts-ignore
|
||||
ref?.current?.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal2 ref={ref}>
|
||||
<Steps path={path} lastBack={close}>
|
||||
<Step id="select">
|
||||
<ModalTitle path={["Create New"]} onClose={close} />
|
||||
<StepSelect />
|
||||
</Step>
|
||||
<Step id="entity" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
|
||||
<StepEntity />
|
||||
</Step>
|
||||
<Step id="entity-fields" path={["action", "entity"]}>
|
||||
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
|
||||
<StepEntityFields />
|
||||
</Step>
|
||||
<Step id="relation" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
|
||||
<StepRelation />
|
||||
</Step>
|
||||
<Step id="create" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
|
||||
<StepCreate />
|
||||
</Step>
|
||||
|
||||
{/* Templates */}
|
||||
{Templates.map(([Component, meta]) => (
|
||||
<Step key={meta.id} id={meta.id} path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
|
||||
<Component />
|
||||
</Step>
|
||||
))}
|
||||
</Steps>
|
||||
</Modal2>
|
||||
);
|
||||
});
|
||||
|
||||
export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema };
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlignJustified,
|
||||
IconAugmentedReality,
|
||||
IconBox,
|
||||
IconCirclesRelation,
|
||||
IconInfoCircle
|
||||
} from "@tabler/icons-react";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbCirclesRelation, TbSettings } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { IconButton, type IconType } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { ModalBody, ModalFooter } from "ui/components/modal/Modal2";
|
||||
import { useStepContext } from "ui/components/steps/Steps";
|
||||
import type { TCreateModalSchema } from "ui/modules/data/components/schema/create-modal/CreateModal";
|
||||
|
||||
type ActionItem = SummaryItemProps & {
|
||||
run: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function StepCreate() {
|
||||
const { stepBack, state, close } = useStepContext<TCreateModalSchema>();
|
||||
const [states, setStates] = useState<(boolean | string)[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const $data = useBkndData();
|
||||
|
||||
const items: ActionItem[] = [];
|
||||
if (state.entities?.create) {
|
||||
items.push(
|
||||
...state.entities.create.map((entity) => ({
|
||||
action: "add",
|
||||
Icon: IconBox,
|
||||
type: "Entity",
|
||||
name: entity.name,
|
||||
json: entity,
|
||||
run: async () => await $data.actions.entity.add(entity.name, entity)
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (state.fields?.create) {
|
||||
items.push(
|
||||
...state.fields.create.map((field) => ({
|
||||
action: "add",
|
||||
Icon: IconAlignJustified,
|
||||
type: "Field",
|
||||
name: field.name,
|
||||
json: field,
|
||||
run: async () =>
|
||||
await $data.actions.entity
|
||||
.patch(field.entity)
|
||||
.fields.add(field.name, field.field as any)
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (state.relations?.create) {
|
||||
items.push(
|
||||
...state.relations.create.map((rel) => ({
|
||||
action: "add",
|
||||
Icon: IconCirclesRelation,
|
||||
type: "Relation",
|
||||
name: `${rel.source} -> ${rel.target} (${rel.type})`,
|
||||
json: rel,
|
||||
run: async () => await $data.actions.relations.add(rel)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
setSubmitting(true);
|
||||
for (const item of items) {
|
||||
try {
|
||||
const res = await item.run();
|
||||
setStates((prev) => [...prev, res]);
|
||||
} catch (e) {
|
||||
setStates((prev) => [...prev, (e as any).message]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
"states",
|
||||
states,
|
||||
items,
|
||||
states.length,
|
||||
items.length,
|
||||
states.every((s) => s === true)
|
||||
);
|
||||
if (items.length === states.length && states.every((s) => s === true)) {
|
||||
close();
|
||||
} else {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [states]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody>
|
||||
<div>This is what will be created. Please confirm by clicking "Next".</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, i) => (
|
||||
<SummaryItem key={i} {...item} state={states[i]} />
|
||||
))}
|
||||
</div>
|
||||
{/*<div>{submitting ? "submitting" : "idle"}</div>
|
||||
<div>
|
||||
{states.length}/{items.length}
|
||||
</div>*/}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
nextLabel="Create"
|
||||
next={{
|
||||
onClick: handleCreate,
|
||||
disabled: submitting
|
||||
}}
|
||||
prev={{ onClick: stepBack, disabled: submitting }}
|
||||
debug={{ state }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryItemProps = {
|
||||
Icon: IconType;
|
||||
action: "add" | string;
|
||||
type: string;
|
||||
name: string;
|
||||
json?: object;
|
||||
state?: boolean | string;
|
||||
initialExpanded?: boolean;
|
||||
};
|
||||
|
||||
const SummaryItem: React.FC<SummaryItemProps> = ({
|
||||
Icon,
|
||||
type,
|
||||
name,
|
||||
json,
|
||||
state,
|
||||
action,
|
||||
initialExpanded = false
|
||||
}) => {
|
||||
const [expanded, handlers] = useDisclosure(initialExpanded);
|
||||
const error = typeof state !== "undefined" && state !== true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background mb-2",
|
||||
error && "bg-red-500/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-4 px-2 py-2 items-center">
|
||||
<div className="flex flex-row items-center p-1 bg-primary/5 rounded">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex flex-row flex-grow gap-5">
|
||||
<Desc type="action" name={action} />
|
||||
<Desc type="type" name={type} />
|
||||
<Desc type="name" name={name} />
|
||||
</div>
|
||||
{json && (
|
||||
<IconButton
|
||||
Icon={IconInfoCircle}
|
||||
variant={expanded ? "default" : "ghost"}
|
||||
onClick={handlers.toggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{json && expanded && (
|
||||
<div className="flex flex-col border-t border-t-muted">
|
||||
<JsonViewer json={json} expand={8} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
{error && typeof state === "string" && <div className="text-sm text-red-500">{state}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Desc = ({ type, name }) => (
|
||||
<div className="flex flex-row text-sm font-mono gap-2">
|
||||
<div className="opacity-50">{ucFirst(type)}</div>
|
||||
<div className="font-semibold">{name}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import {
|
||||
EntityFieldsForm,
|
||||
type EntityFieldsFormRef
|
||||
} from "ui/routes/data/forms/entity.fields.form";
|
||||
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
||||
|
||||
const schema = entitiesSchema;
|
||||
|
||||
export function StepEntityFields() {
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const entity = state.entities?.create?.[0]!;
|
||||
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
const {
|
||||
control,
|
||||
formState: { isValid, errors },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
watch,
|
||||
register,
|
||||
setValue
|
||||
} = useForm({
|
||||
mode: "onTouched",
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: {
|
||||
...entity,
|
||||
fields: defaultFields,
|
||||
config: {
|
||||
sort_field: "id",
|
||||
sort_dir: "asc"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const values = watch();
|
||||
|
||||
const updateListener = useEvent((data: TAppDataEntityFields) => {
|
||||
console.log("updateListener", data);
|
||||
setValue("fields", data as any);
|
||||
});
|
||||
|
||||
function handleNext() {
|
||||
if (isValid && ref.current?.isValid()) {
|
||||
setState((prev) => {
|
||||
const entity = prev.entities?.create?.[0];
|
||||
if (!entity) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
create: [getValues() as any]
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
nextStep("create")();
|
||||
} else {
|
||||
console.warn("not valid");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleNext)}>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Add fields to <strong>{entity.name}</strong>:
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<EntityFieldsForm ref={ref} fields={defaultFields} onChange={updateListener} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>How should it be sorted by default?</p>
|
||||
<div className="flex flex-row gap-2">
|
||||
<MantineSelect
|
||||
label="Field"
|
||||
data={Object.keys(values.fields).filter((name) => name.length > 0)}
|
||||
placeholder="Select field"
|
||||
name="config.sort_field"
|
||||
allowDeselect={false}
|
||||
control={control}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Direction"
|
||||
data={["asc", "desc"]}
|
||||
defaultValue="asc"
|
||||
placeholder="Select direction"
|
||||
name="config.sort_dir"
|
||||
allowDeselect={false}
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<p key={key}>
|
||||
{key}: {value.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
disabled: !isValid,
|
||||
type: "submit"
|
||||
//onClick: handleNext
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
debug={{ state, values }}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
|
||||
import { TextInput, Textarea } from "@mantine/core";
|
||||
import { useFocusTrap } from "@mantine/hooks";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
type TCreateModalSchema,
|
||||
entitySchema,
|
||||
useStepContext
|
||||
} from "./CreateModal";
|
||||
|
||||
export function StepEntity() {
|
||||
const focusTrapRef = useFocusTrap();
|
||||
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { register, handleSubmit, formState, watch } = useForm({
|
||||
mode: "onTouched",
|
||||
resolver: typeboxResolver(entitySchema),
|
||||
defaultValues: state.entities?.create?.[0] ?? {}
|
||||
});
|
||||
/*const data = watch();
|
||||
console.log("state", { isValid });
|
||||
console.log("schema", JSON.stringify(entitySchema));
|
||||
console.log("data", JSON.stringify(data));*/
|
||||
|
||||
function onSubmit(data: any) {
|
||||
console.log(data);
|
||||
setState((prev) => {
|
||||
const prevEntity = prev.entities?.create?.[0];
|
||||
if (prevEntity && prevEntity.name !== data.name) {
|
||||
return { ...prev, entities: { create: [{ ...data, fields: prevEntity.fields }] } };
|
||||
}
|
||||
|
||||
return { ...prev, entities: { create: [data] } };
|
||||
});
|
||||
|
||||
if (formState.isValid) {
|
||||
console.log("would go next");
|
||||
nextStep("entity-fields")();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} ref={focusTrapRef}>
|
||||
<ModalBody>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
error={formState.errors.name?.message}
|
||||
{...register("name")}
|
||||
placeholder="posts"
|
||||
size="md"
|
||||
label="What's the name of the entity?"
|
||||
description="Use plural form, and all lowercase. It will be used as the database table."
|
||||
/>
|
||||
{/*<input type="submit" value="submit" />*/}
|
||||
<TextInput
|
||||
{...register("config.name")}
|
||||
error={formState.errors.config?.name?.message}
|
||||
placeholder="Posts"
|
||||
size="md"
|
||||
label="How should it be called?"
|
||||
description="Use plural form. This will be used to display in the UI."
|
||||
/>
|
||||
<TextInput
|
||||
{...register("config.name_singular")}
|
||||
error={formState.errors.config?.name_singular?.message}
|
||||
placeholder="Post"
|
||||
size="md"
|
||||
label="What's the singular form of it?"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="This is a post (optional)"
|
||||
error={formState.errors.config?.description?.message}
|
||||
{...register("config.description")}
|
||||
size="md"
|
||||
label={"Description"}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
type: "submit",
|
||||
disabled: !formState.isValid
|
||||
//onClick:
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
debug={{ state }}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { Select, Switch, TextInput } from "@mantine/core";
|
||||
import { TypeRegistry } from "@sinclair/typebox";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
StringIdentifier,
|
||||
Type,
|
||||
registerCustomTypeboxKinds
|
||||
} from "core/utils";
|
||||
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
||||
import { useBknd } from "ui/client";
|
||||
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import { useStepContext } from "ui/components/steps/Steps";
|
||||
import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal";
|
||||
|
||||
// @todo: check if this could become an issue
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
const Relations: {
|
||||
type: RelationType;
|
||||
label: string;
|
||||
component: (props: ComponentCtx<any>) => ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
type: RelationTypes.ManyToOne,
|
||||
label: "Many to One",
|
||||
component: ManyToOne
|
||||
},
|
||||
{
|
||||
type: RelationTypes.OneToOne,
|
||||
label: "One to One",
|
||||
component: OneToOne
|
||||
},
|
||||
{
|
||||
type: RelationTypes.ManyToMany,
|
||||
label: "Many to Many",
|
||||
component: ManyToMany
|
||||
},
|
||||
{
|
||||
type: RelationTypes.Polymorphic,
|
||||
label: "Polymorphic",
|
||||
component: Polymorphic
|
||||
}
|
||||
];
|
||||
|
||||
const schema = Type.Object({
|
||||
type: StringEnum(Relations.map((r) => r.type)),
|
||||
source: StringIdentifier,
|
||||
target: StringIdentifier,
|
||||
config: Type.Object({})
|
||||
});
|
||||
|
||||
type ComponentCtx<T extends FieldValues = FieldValues> = {
|
||||
register: UseFormRegister<T>;
|
||||
control: Control<T>;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export function StepRelation() {
|
||||
const { config } = useBknd();
|
||||
const entities = config.data.entities;
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isValid },
|
||||
setValue,
|
||||
watch,
|
||||
control
|
||||
} = useForm({
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>
|
||||
});
|
||||
const data = watch();
|
||||
console.log("data", { data, schema });
|
||||
|
||||
function handleNext() {
|
||||
if (isValid) {
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
relations: {
|
||||
create: [data]
|
||||
}
|
||||
};
|
||||
});
|
||||
console.log("data", data);
|
||||
nextStep("create")();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(handleNext)}>
|
||||
<ModalBody>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<MantineSelect
|
||||
control={control}
|
||||
name="source"
|
||||
label="Source"
|
||||
allowDeselect={false}
|
||||
data={Object.entries(entities ?? {}).map(([name, entity]) => ({
|
||||
value: name,
|
||||
label: entity.config?.name ?? name,
|
||||
disabled: data.target === name
|
||||
}))}
|
||||
/>
|
||||
<MantineSelect
|
||||
control={control}
|
||||
name="type"
|
||||
onChange={() => setValue("config", {})}
|
||||
label="Relation Type"
|
||||
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<MantineSelect
|
||||
control={control}
|
||||
allowDeselect={false}
|
||||
name="target"
|
||||
label="Target"
|
||||
data={Object.entries(entities ?? {}).map(([name, entity]) => ({
|
||||
value: name,
|
||||
label: entity.config?.name ?? name,
|
||||
disabled: data.source === name
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{data.type &&
|
||||
Relations.find((r) => r.type === data.type)?.component({
|
||||
register,
|
||||
control,
|
||||
data
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
type: "submit",
|
||||
disabled: !isValid,
|
||||
onClick: handleNext
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
debug={{ state, data }}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Pre = ({ children }: { children: ReactNode }) => (
|
||||
<b className="font-mono select-text">{children}</b>
|
||||
);
|
||||
|
||||
const Callout = ({ children }: { children: ReactNode }) => (
|
||||
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
|
||||
);
|
||||
|
||||
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Target mapping"
|
||||
{...register("config.mappedBy")}
|
||||
placeholder={target}
|
||||
/>
|
||||
|
||||
<MantineNumberInput
|
||||
label="Cardinality"
|
||||
name="config.sourceCardinality"
|
||||
control={control}
|
||||
placeholder="n"
|
||||
/>
|
||||
<MantineNumberInput
|
||||
label="WITH limit"
|
||||
name="config.with_limit"
|
||||
control={control}
|
||||
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
||||
/>
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Source mapping"
|
||||
{...register("config.inversedBy")}
|
||||
placeholder={source}
|
||||
/>
|
||||
<Switch label="Required" {...register("config.required")} />
|
||||
</div>
|
||||
</div>
|
||||
{source && target && config && (
|
||||
<Callout>
|
||||
<>
|
||||
<p>
|
||||
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
|
||||
</p>
|
||||
<p>
|
||||
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
|
||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||
</p>
|
||||
<p>
|
||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||
<i>{config.required ? "required" : "optional"}</i>.
|
||||
</p>
|
||||
{config.sourceCardinality ? (
|
||||
<p>
|
||||
<Pre>{source}</Pre> should not have more than{" "}
|
||||
<Pre>{config.sourceCardinality}</Pre> referencing entr
|
||||
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OneToOne({
|
||||
register,
|
||||
control,
|
||||
data: {
|
||||
source,
|
||||
target,
|
||||
config: { mappedBy, required }
|
||||
}
|
||||
}: ComponentCtx) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Target mapping"
|
||||
{...register("config.mappedBy")}
|
||||
placeholder={target}
|
||||
/>
|
||||
<Switch label="Required" {...register("config.required")} />
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Source mapping"
|
||||
{...register("config.inversedBy")}
|
||||
placeholder={source}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<p>
|
||||
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
|
||||
<Pre>{target}</Pre>.
|
||||
</p>
|
||||
<p>
|
||||
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
|
||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||
</p>
|
||||
<p>
|
||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||
<i>{required ? "required" : "optional"}</i>.
|
||||
</p>
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManyToMany({ register, control, data: { source, target, config } }: ComponentCtx) {
|
||||
const table = config.connectionTable
|
||||
? config.connectionTable
|
||||
: source && target
|
||||
? `${source}_${target}`
|
||||
: "";
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/*<TextInput
|
||||
label="Target mapping"
|
||||
{...register("config.mappedBy")}
|
||||
placeholder={target}
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Connection Table"
|
||||
{...register("config.connectionTable")}
|
||||
placeholder={table}
|
||||
/>
|
||||
<TextInput
|
||||
label="Connection Mapping"
|
||||
{...register("config.connectionTableMappedName")}
|
||||
placeholder={table}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/*<TextInput
|
||||
label="Source mapping"
|
||||
{...register("config.inversedBy")}
|
||||
placeholder={source}
|
||||
/>*/}
|
||||
</div>
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<p>
|
||||
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||
</p>
|
||||
<p>
|
||||
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
||||
</p>
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Polymorphic({ register, control, data: { type, source, target, config } }: ComponentCtx) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-8" key={type}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Target mapping"
|
||||
{...register("config.mappedBy")}
|
||||
placeholder={target}
|
||||
/>
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Source mapping"
|
||||
{...register("config.inversedBy")}
|
||||
placeholder={source}
|
||||
/>
|
||||
<MantineNumberInput
|
||||
label="Cardinality"
|
||||
name="config.targetCardinality"
|
||||
control={control}
|
||||
placeholder="n"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<p>
|
||||
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||
</p>
|
||||
<p>
|
||||
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
|
||||
<Pre>entity_id</Pre> to make the (polymorphic) reference.
|
||||
</p>
|
||||
{config.targetCardinality ? (
|
||||
<p>
|
||||
<Pre>{source}</Pre> should not have more than{" "}
|
||||
<Pre>{config.targetCardinality}</Pre> reference
|
||||
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { IconType } from "react-icons";
|
||||
import { TbBox, TbCirclesRelation, TbPhoto } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
type ModalActions,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
type TCreateModalSchema,
|
||||
type TSchemaAction,
|
||||
useStepContext
|
||||
} from "./CreateModal";
|
||||
import Templates from "./templates/register";
|
||||
|
||||
export function StepSelect() {
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const selected = state.action ?? null;
|
||||
|
||||
function handleSelect(action: TSchemaAction) {
|
||||
if (selected === action) {
|
||||
nextStep(action)();
|
||||
return;
|
||||
}
|
||||
setState({ action });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody>
|
||||
<p>Choose what type to add.</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<RadioCard
|
||||
Icon={TbBox}
|
||||
title="Entity"
|
||||
description="Create a new entity with fields"
|
||||
onClick={() => handleSelect("entity")}
|
||||
selected={selected === "entity"}
|
||||
/>
|
||||
<RadioCard
|
||||
Icon={TbCirclesRelation}
|
||||
title="Relation"
|
||||
description="Create a new relation between entities"
|
||||
onClick={() => handleSelect("relation")}
|
||||
selected={selected === "relation"}
|
||||
/>
|
||||
{/*<RadioCard
|
||||
Icon={TbPhoto}
|
||||
title="Attach Media"
|
||||
description="Attach media to an entity"
|
||||
onClick={() => handleSelect("media")}
|
||||
selected={selected === "media"}
|
||||
/>*/}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-3">
|
||||
<h3 className="font-bold">Quick templates</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Templates.map(([, template]) => (
|
||||
<RadioCard
|
||||
key={template.id}
|
||||
compact
|
||||
Icon={TbPhoto}
|
||||
title={template.title}
|
||||
description={template.description}
|
||||
onClick={() => handleSelect(template.id)}
|
||||
selected={selected === template.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
onClick: selected && nextStep(selected),
|
||||
disabled: !selected
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
prevLabel="Cancel"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const RadioCard = ({
|
||||
Icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
selected,
|
||||
compact = false,
|
||||
disabled = false
|
||||
}: {
|
||||
Icon: IconType;
|
||||
title: string;
|
||||
description?: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={disabled !== true ? onClick : undefined}
|
||||
className={twMerge(
|
||||
"flex gap-3 border border-primary/10 rounded cursor-pointer",
|
||||
compact ? "flex-row p-4 items-center" : "flex-col p-5",
|
||||
selected ? "bg-primary/10 border-primary/50" : "hover:bg-primary/5",
|
||||
disabled && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-10" />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<p className="text-lg font-bold">{title}</p>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TemplateMediaComponent } from "./template.media.component";
|
||||
export { TemplateMediaMeta } from "./template.media.meta";
|
||||
@@ -0,0 +1,183 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { Radio, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Default,
|
||||
type Static,
|
||||
StringEnum,
|
||||
StringIdentifier,
|
||||
Type,
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import type { MediaFieldConfig } from "media/MediaField";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useBknd } from "ui/client";
|
||||
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
|
||||
import { MantineRadio } from "ui/components/form/hook-form-mantine/MantineRadio";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
type TCreateModalSchema,
|
||||
type TFieldCreate,
|
||||
useStepContext
|
||||
} from "../../CreateModal";
|
||||
|
||||
const schema = Type.Object({
|
||||
entity: StringIdentifier,
|
||||
cardinality_type: StringEnum(["single", "multiple"], { default: "multiple" }),
|
||||
cardinality: Type.Optional(Type.Number({ minimum: 1 })),
|
||||
name: StringIdentifier
|
||||
});
|
||||
type TCreateModalMediaSchema = Static<typeof schema>;
|
||||
|
||||
export function TemplateMediaComponent() {
|
||||
const { stepBack, setState, state, nextStep } = useStepContext<TCreateModalSchema>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isValid },
|
||||
setValue,
|
||||
watch,
|
||||
control
|
||||
} = useForm({
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: Default(schema, {}) as TCreateModalMediaSchema
|
||||
});
|
||||
|
||||
const { config } = useBknd();
|
||||
const media_entity = config.media.entity_name ?? "media";
|
||||
const entities = transformObject(config.data.entities ?? {}, (entity, name) =>
|
||||
name !== media_entity ? entity : undefined
|
||||
);
|
||||
const data = watch();
|
||||
|
||||
async function handleCreate() {
|
||||
if (isValid) {
|
||||
console.log("data", data);
|
||||
const { field, relation } = convert(media_entity, data);
|
||||
|
||||
console.log("state", { field, relation });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fields: { create: [field] },
|
||||
relations: { create: [relation] }
|
||||
}));
|
||||
|
||||
nextStep("create")();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(handleCreate)}>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-6">
|
||||
<MantineSelect
|
||||
name="entity"
|
||||
allowDeselect={false}
|
||||
control={control}
|
||||
size="md"
|
||||
label="Choose which entity to add media to"
|
||||
required
|
||||
data={Object.entries(entities).map(([name, entity]) => ({
|
||||
value: name,
|
||||
label: entity.config?.name ?? name
|
||||
}))}
|
||||
/>
|
||||
<MantineRadio.Group
|
||||
name="cardinality_type"
|
||||
control={control}
|
||||
label="How many items can be attached?"
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Radio label="Multiple items" value="multiple" />
|
||||
<Radio label="Single item" value="single" />
|
||||
</div>
|
||||
</MantineRadio.Group>
|
||||
{data.cardinality_type === "multiple" && (
|
||||
<MantineNumberInput
|
||||
name="cardinality"
|
||||
control={control}
|
||||
size="md"
|
||||
label="How many exactly?"
|
||||
placeholder="n"
|
||||
description="Leave empty for unlimited"
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
size="md"
|
||||
label="Set a name for the property"
|
||||
required
|
||||
description={`A virtual property will be added to ${
|
||||
data.entity ? data.entity : "the entity"
|
||||
}.`}
|
||||
{...register("name")}
|
||||
/>
|
||||
</div>
|
||||
{/*<p>step template media</p>
|
||||
<pre>{JSON.stringify(state, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>*/}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
type: "submit",
|
||||
disabled: !isValid
|
||||
}}
|
||||
prev={{
|
||||
onClick: stepBack
|
||||
}}
|
||||
debug={{ state, data }}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function convert(media_entity: string, data: TCreateModalMediaSchema) {
|
||||
const field: {
|
||||
entity: string;
|
||||
name: string;
|
||||
field: { type: "media"; config: MediaFieldConfig };
|
||||
} = {
|
||||
name: data.name,
|
||||
entity: data.entity,
|
||||
field: {
|
||||
type: "media" as any,
|
||||
config: {
|
||||
required: false,
|
||||
fillable: ["update"],
|
||||
hidden: false,
|
||||
mime_types: [],
|
||||
virtual: true,
|
||||
entity: data.entity
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const relation = {
|
||||
type: "poly",
|
||||
source: data.entity,
|
||||
target: media_entity,
|
||||
config: {
|
||||
mappedBy: data.name,
|
||||
targetCardinality: data.cardinality_type === "single" ? 1 : undefined
|
||||
}
|
||||
};
|
||||
|
||||
if (data.cardinality_type === "multiple") {
|
||||
if (data.cardinality && data.cardinality > 1) {
|
||||
field.field.config.max_items = data.cardinality;
|
||||
relation.config.targetCardinality = data.cardinality;
|
||||
}
|
||||
} else {
|
||||
field.field.config.max_items = 1;
|
||||
relation.config.targetCardinality = 1;
|
||||
}
|
||||
|
||||
// force fix types
|
||||
const _field = field as unknown as TFieldCreate;
|
||||
|
||||
return { field: _field, relation };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TbPhoto } from "react-icons/tb";
|
||||
import type { StepTemplate } from "../register";
|
||||
|
||||
export const TemplateMediaMeta = {
|
||||
id: "template-media",
|
||||
title: "Attach Media",
|
||||
description: "Attach media to an entity",
|
||||
Icon: TbPhoto
|
||||
} satisfies StepTemplate;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IconType } from "react-icons";
|
||||
import { TemplateMediaComponent, TemplateMediaMeta } from "./media";
|
||||
|
||||
export type StepTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
Icon: IconType;
|
||||
};
|
||||
|
||||
const Templates: [() => JSX.Element, StepTemplate][] = [
|
||||
[TemplateMediaComponent, TemplateMediaMeta]
|
||||
];
|
||||
|
||||
export default Templates;
|
||||
67
app/src/ui/modules/data/hooks/useEntityForm.tsx
Normal file
67
app/src/ui/modules/data/hooks/useEntityForm.tsx
Normal 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 };
|
||||
}
|
||||
158
app/src/ui/modules/flows/components/FlowCanvas.tsx
Normal file
158
app/src/ui/modules/flows/components/FlowCanvas.tsx
Normal 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>;
|
||||
};
|
||||
71
app/src/ui/modules/flows/components/TriggerComponent.tsx
Normal file
71
app/src/ui/modules/flows/components/TriggerComponent.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/src/ui/modules/flows/components/form/TaskForm.tsx
Normal file
41
app/src/ui/modules/flows/components/form/TaskForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
app/src/ui/modules/flows/components/form/TemplateField.tsx
Normal file
94
app/src/ui/modules/flows/components/form/TemplateField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
52
app/src/ui/modules/flows/components/tasks/TaskComponent.tsx
Normal file
52
app/src/ui/modules/flows/components/tasks/TaskComponent.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
app/src/ui/modules/flows/components/tasks/index.ts
Normal file
2
app/src/ui/modules/flows/components/tasks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { FetchTaskComponent } from "./FetchTaskComponent";
|
||||
import { TaskComponent } from "./TaskComponent";
|
||||
109
app/src/ui/modules/flows/components2/form/KeyValueInput.tsx
Normal file
109
app/src/ui/modules/flows/components2/form/KeyValueInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
app/src/ui/modules/flows/components2/hud/FlowPanel.tsx
Normal file
79
app/src/ui/modules/flows/components2/hud/FlowPanel.tsx
Normal 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;
|
||||
148
app/src/ui/modules/flows/components2/nodes/BaseNode.tsx
Normal file
148
app/src/ui/modules/flows/components2/nodes/BaseNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
app/src/ui/modules/flows/components2/nodes/Handle.tsx
Normal file
31
app/src/ui/modules/flows/components2/nodes/Handle.tsx
Normal 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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
app/src/ui/modules/flows/components2/nodes/SelectNode.tsx
Normal file
107
app/src/ui/modules/flows/components2/nodes/SelectNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/src/ui/modules/flows/components2/nodes/index.ts
Normal file
9
app/src/ui/modules/flows/components2/nodes/index.ts
Normal 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
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
];
|
||||
209
app/src/ui/modules/flows/hooks/use-flow/index.tsx
Normal file
209
app/src/ui/modules/flows/hooks/use-flow/index.tsx
Normal 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;
|
||||
}
|
||||
6
app/src/ui/modules/flows/hooks/use-flow/state.ts
Normal file
6
app/src/ui/modules/flows/hooks/use-flow/state.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
const flowStateAtom = atom({
|
||||
dirty: false,
|
||||
flow: undefined
|
||||
});
|
||||
89
app/src/ui/modules/flows/utils/index.ts
Normal file
89
app/src/ui/modules/flows/utils/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
413
app/src/ui/modules/media/components/dropzone/Dropzone.tsx
Normal file
413
app/src/ui/modules/media/components/dropzone/Dropzone.tsx
Normal 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>;
|
||||
};
|
||||
262
app/src/ui/modules/media/components/dropzone/file-selector.ts
Normal file
262
app/src/ui/modules/media/components/dropzone/file-selector.ts
Normal 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[];
|
||||
79
app/src/ui/modules/media/components/dropzone/use-dropzone.ts
Normal file
79
app/src/ui/modules/media/components/dropzone/use-dropzone.ts
Normal 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 };
|
||||
31
app/src/ui/modules/media/helper.ts
Normal file
31
app/src/ui/modules/media/helper.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user