public commit

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

View File

@@ -0,0 +1,60 @@
import { IconFingerprint } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
export function AuthRoot({ children }) {
const { app, config } = useBknd();
const users_entity = config.auth.entity_name;
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["auth"])}>
<IconButton Icon={TbSettings} />
</Link>
}
>
Auth
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href={"/"}>
Overview
</AppShell.SidebarLink>
<AppShell.SidebarLink
as={Link}
href={app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity))}
>
Users
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.roles.list()}>
Roles & Permissions
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
Strategies
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>
Settings
</AppShell.SidebarLink>
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
export function AuthEmpty() {
useBrowserTitle(["Auth"]);
return <Empty Icon={IconFingerprint} title="Not implemented yet" />;
}

View File

@@ -0,0 +1,92 @@
import { useBknd, useClient } from "ui/client";
import { routes } from "ui/lib/routes";
import {
Button,
ButtonLink,
type ButtonLinkProps,
type ButtonProps
} from "../../components/buttons/Button";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthIndex() {
const client = useClient();
const { app, config } = useBknd();
const users_entity = config.auth.entity_name;
const query = client.query().data.entity("users").count();
const usersTotal = query.data?.body.count ?? 0;
const {
config: {
auth: { roles, strategies }
}
} = useBknd();
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
const usersLink = app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity));
const rolesLink = routes.auth.roles.list();
const strategiesLink = app.getSettingsPath(["auth", "strategies"]);
return (
<>
<AppShell.SectionHeader>Overview</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-3">
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
<KpiCard
title="Users registered"
value={usersTotal}
actions={[
{
label: "View all",
href: usersLink
},
{ label: "Add new", variant: "default", href: usersLink }
]}
/>
<KpiCard
title="Roles"
value={rolesTotal}
actions={[
{ label: "View all", href: rolesLink },
{ label: "Add new", variant: "default", href: rolesLink }
]}
/>
<KpiCard
title="Strategies enabled"
value={strategiesTotal}
actions={[
{ label: "View all", href: strategiesLink },
{ label: "Add new", variant: "default", href: strategiesLink }
]}
/>
</div>
</div>
</AppShell.Scrollable>
</>
);
}
type KpiCardProps = {
title: string;
value: number;
actions: (Omit<ButtonLinkProps, "href"> & { label: string; href?: string })[];
};
const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
<div className="flex flex-col border border-muted">
<div className="flex flex-col gap-2 px-5 pt-3.5 pb-4 border-b border-b-muted">
<div>
<span className="opacity-50">{title}</span>
{/*<span>+6.1%</span>*/}
</div>
<div className="text-4xl font-medium">{value}</div>
</div>
<div className="flex flex-row gap-3 p-3 justify-between">
{actions.map((action, i) => (
<ButtonLink key={i} size="small" variant="ghost" href="#" {...action}>
{action.label}
</ButtonLink>
))}
</div>
</div>
);

View File

@@ -0,0 +1,121 @@
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
import { Type, ucFirst, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { transform } from "lodash-es";
import { useEffect, useState } from "react";
import { useAuth } from "ui/client";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Button } from "ui/components/buttons/Button";
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search";
import { LoginForm } from "ui/modules/auth/LoginForm";
import { useLocation } from "wouter";
import * as AppShell from "../../layouts/AppShell/AppShell";
const schema = Type.Object({
token: Type.String()
});
export function AuthLogin() {
useBrowserTitle(["Login"]);
const [, navigate] = useLocation();
const search = useSearch(schema);
const token = search.value.token;
//console.log("search", token, "/api/auth/google?redirect=" + window.location.href);
const auth = useAuth();
const { strategies, loading } = useAuthStrategies();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (token) {
auth.setToken(token);
}
}, [token]);
async function handleSubmit(value: { email: string; password: string }) {
console.log("submit", value);
const { res, data } = await auth.login(value);
if (!res.ok) {
if (data && "error" in data) {
setError(data.error.message);
} else {
setError("An error occurred");
}
} else if (error) {
setError(null);
}
console.log("res:login", { res, data });
}
if (auth.user) {
console.log("user set", auth.user);
navigate("/", { replace: true });
}
const oauth = transform(
strategies ?? {},
(result, value, key) => {
if (value.type !== "password") {
result[key] = value.config;
}
},
{}
) as Record<string, AppAuthOAuthStrategy>;
console.log("oauth", oauth, strategies);
return (
<AppShell.Root>
<AppShell.Content center>
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
{error && (
<div className="bg-red-500/40 p-3 w-full rounded font-bold mb-1">
<span>{error}</span>
</div>
)}
<div className="flex flex-col gap-4 w-full">
{Object.keys(oauth).length > 0 && (
<>
{Object.entries(oauth)?.map(([name, oauth], key) => (
<Button
key={key}
size="large"
variant="outline"
className="justify-center"
onClick={() => {
window.location.href = `/api/auth/${name}/login?redirect=${window.location.href}`;
}}
>
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
</Button>
))}
<div className="w-full flex flex-row items-center">
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
<div className="mx-5">or</div>
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
</div>
</>
)}
<LoginForm onSubmitted={handleSubmit} />
</div>
</div>
)}
</AppShell.Content>
</AppShell.Root>
);
}

View File

@@ -0,0 +1,90 @@
import { notifications } from "@mantine/notifications";
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
export function AuthRolesEdit(props) {
useBknd({ withSecrets: true });
return <AuthRolesEditInternal {...props} />;
}
function AuthRolesEditInternal({ params }) {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const roleName = params.role;
const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
async function handleUpdate() {
console.log("data", formRef.current?.isValid());
if (!formRef.current?.isValid()) return;
const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data);
notifications.show({
id: `role-${roleName}-update`,
position: "top-right",
title: success ? "Update success" : "Update failed",
message: success ? "Role updated successfully" : "Failed to update role",
color: !success ? "red" : undefined
});
}
async function handleDelete() {
if (await actions.roles.delete(roleName)) {
navigate(routes.auth.roles.list());
}
}
return (
<>
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["auth", "roles", roleName]), {
absolute: true
})
},
{
label: "Delete",
onClick: handleDelete,
destructive: true
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Button variant="primary" onClick={handleUpdate}>
Update
</Button>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[
{ label: "Roles & Permissions", href: routes.auth.roles.list() },
{ label: roleName }
]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable>
<AuthRoleForm ref={formRef} role={role} />
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,128 @@
import { Modal, TextInput } from "@mantine/core";
import { useDisclosure, useFocusTrap } from "@mantine/hooks";
import { StringIdentifier, transformObject, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useRef } from "react";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import { bkndModals } from "ui/modals";
import { SchemaFormModal } from "ui/modals/debug/SchemaFormModal";
import { useBknd } from "../../client/BkndProvider";
import { Button } from "../../components/buttons/Button";
import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
export function AuthRolesList() {
const [navigate] = useNavigate();
const [modalOpen, modalHandler] = useDisclosure(false);
const focusRef = useFocusTrap();
const inputRef = useRef<HTMLInputElement>(null);
const { config, actions } = useBkndAuth();
const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({
role: name,
permissions: role.permissions,
is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false
}))
);
function handleClick(row) {
navigate(routes.auth.roles.edit(row.role));
}
function openCreateModal() {
bkndModals.open(
"form",
{
schema: {
type: "object",
properties: {
name: StringIdentifier
},
required: ["name"]
},
uiSchema: {
name: {
"ui:title": "Role name"
}
},
onSubmit: async (data) => {
if (data.name.length > 0) {
if (await actions.roles.add(data.name)) {
navigate(routes.auth.roles.edit(data.name));
}
}
}
},
{
title: "New Role"
}
);
}
return (
<>
{/*<Modal
ref={focusRef}
opened={modalOpen}
onClose={modalHandler.close}
title={"New Role"}
classNames={{
root: "bknd-admin",
header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px",
content: "rounded-lg select-none",
title: "font-bold !text-md",
body: "pt-3 pb-3 px-3 gap-4 flex flex-col"
}}
>
<TextInput ref={inputRef} data-autofocus size="md" placeholder="Enter role name" />
<div className="flex flex-row justify-end gap-2">
<Button onClick={() => modalHandler.close()}>Cancel</Button>
<Button variant="primary" onClick={handleClickAdd}>
Create
</Button>
</div>
</Modal>*/}
<AppShell.SectionHeader
right={
<Button variant="primary" onClick={openCreateModal}>
Create new
</Button>
}
>
Roles & Permissions
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-3">
<DataTable
data={data}
renderValue={renderValue}
renderHeader={ucFirstAllSnakeToPascalWithSpaces}
onClickRow={handleClick}
/>
</div>
</AppShell.Scrollable>
</>
);
}
const renderValue = ({ value, property }) => {
if (["is_default", "implicit_allow"].includes(property)) {
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
}
if (property === "permissions") {
return [...(value || [])].map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
));
}
return <CellValue value={value} property={property} />;
};

View File

@@ -0,0 +1,79 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthSettingsList() {
useBknd({ withSecrets: true });
return <AuthSettingsListInternal />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
};
function AuthSettingsListInternal() {
const s = useBknd();
const config = s.config.auth;
const schema = cloneDeep(omit(s.schema.auth, ["title"]));
const [generalSchema, generalConfig, extracted] = extractSchema(schema as any, config, [
"jwt",
"roles",
"guard",
"strategies"
]);
try {
const user_entity = config.entity_name ?? "users";
const entities = s.config.data.entities ?? {};
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean);
if (user_fields) {
console.log("user_fields", user_fields);
extracted.jwt.schema.properties.fields.items.enum = user_fields;
extracted.jwt.schema.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
}
} catch (e) {
console.error(e);
}
console.log({ generalSchema, generalConfig, extracted });
return (
<>
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
Settings
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow px-5 py-4 gap-8">
<div>
<JsonSchemaForm
schema={generalSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>
</AppShell.Scrollable>
</>
);
}
function AuthJwtSettings() {}

View File

@@ -0,0 +1,57 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthStrategiesList() {
useBknd({ withSecrets: true });
return <AuthStrategiesListInternal />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
};
function AuthStrategiesListInternal() {
const s = useBknd();
const config = s.config.auth.strategies;
const schema = cloneDeep(omit(s.schema.auth.properties.strategies, ["title"]));
console.log("strategies", { config, schema });
return (
<>
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
Strategies
</AppShell.SectionHeader>
<AppShell.Scrollable>
strat
{/*<div className="flex flex-col flex-grow px-5 py-4 gap-8">
<div>
<JsonSchemaForm
schema={generalSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>*/}
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,3 @@
export function AuthUsersList() {
return null;
}

View File

@@ -0,0 +1,152 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, Switch, Tooltip } from "@mantine/core";
import { guardRoleSchema } from "auth/auth-schema";
import { type Static, ucFirst } from "core/utils";
import type { TAppDataEntityFields } from "data/data-schema";
import { forwardRef, useImperativeHandle } from "react";
import { type UseControllerProps, useController, useForm } from "react-hook-form";
import { Button, useBknd } from "ui";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
const schema = guardRoleSchema;
type Role = Static<typeof guardRoleSchema>;
export type AuthRoleFormRef = {
getData: () => Role;
isValid: () => boolean;
reset: () => void;
};
export const AuthRoleForm = forwardRef<
AuthRoleFormRef,
{
role?: Role;
debug?: boolean;
}
>(({ role, debug }, ref) => {
const { permissions } = useBknd();
const {
formState: { isValid },
watch,
control,
reset,
getValues
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: role
});
useImperativeHandle(ref, () => ({
reset,
getData: () => getValues(),
isValid: () => isValid
}));
return (
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2">
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
<Permissions control={control} permissions={permissions} />
</div>
<div className="flex flex-col gap-4">
<Input.Wrapper
label="Should this role be the default?"
size="md"
description="In case an user is not assigned any role, this role will be assigned by default."
>
<div className="flex flex-row">
<MantineSwitch name="is_default" control={control} className="mt-2" />
</div>
</Input.Wrapper>
<Input.Wrapper
label="Implicit allow missing permissions?"
size="md"
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
>
<div className="flex flex-row">
<MantineSwitch name="implicit_allow" control={control} className="mt-2" />
</div>
</Input.Wrapper>
</div>
{debug && (
<div className="font-mono opacity-50">
<div>{JSON.stringify(role, null, 2)}</div>
<div>{JSON.stringify(watch(), null, 2)}</div>
</div>
)}
</div>
);
});
const Permissions = ({
control,
permissions
}: Omit<UseControllerProps, "name"> & { permissions: string[] }) => {
const {
field: { value, onChange: fieldOnChange, ...field },
fieldState
} = useController<Static<typeof schema>, "permissions">({
name: "permissions",
control
});
const data = value ?? [];
function handleChange(permission: string) {
return (e) => {
const checked = e.target.checked;
const newValue = checked ? [...data, permission] : data.filter((p) => p !== permission);
fieldOnChange(newValue);
};
}
const grouped = permissions.reduce(
(acc, permission) => {
const [group, name] = permission.split(".") as [string, string];
if (!acc[group]) acc[group] = [];
acc[group].push(permission);
return acc;
},
{} as Record<string, string[]>
);
console.log("grouped", grouped);
//console.log("fieldState", fieldState, value);
return (
<div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, permissions]) => {
return (
<div className="flex flex-col gap-2" key={group}>
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
{permissions.map((permission) => {
const selected = data.includes(permission);
return (
<div key={permission} className="flex flex-col border border-muted">
<div className="flex flex-row gap-2 justify-between">
<div className="py-4 px-4 font-mono leading-none">
{permission}
</div>
<div className="flex flex-row gap-1 items-center px-2">
<Switch
checked={selected}
onChange={handleChange(permission)}
/>
<Tooltip label="Coming soon">
<Button size="small" variant="ghost" disabled>
<span className="font-normal italic font-mono">FX</span>
</Button>
</Tooltip>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Route } from "wouter";
import { AuthRoot } from "./_auth.root";
import { AuthIndex } from "./auth.index";
import { AuthRolesList } from "./auth.roles";
import { AuthRolesEdit } from "./auth.roles.edit.$role";
import { AuthSettingsList } from "./auth.settings";
import { AuthStrategiesList } from "./auth.strategies";
import { AuthUsersList } from "./auth.users";
export default function AuthRoutes() {
return (
<AuthRoot>
<Route path="/" component={AuthIndex} />
<Route path="/users" component={AuthUsersList} />
<Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettingsList} />
</AuthRoot>
);
}

View File

@@ -0,0 +1,136 @@
import { SegmentedControl } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import type { Entity, TEntityType } from "data";
import { twMerge } from "tailwind-merge";
import { useBknd } from "../../client";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
export function DataRoot({ children }) {
// @todo: settings routes should be centralized
const {
app: { entities }
} = useBknd();
const entityList: Record<TEntityType, Entity[]> = {
regular: [],
generated: [],
system: []
} as const;
const [navigate] = useNavigate();
const context = window.location.href.match(/\/schema/) ? "schema" : "data";
for (const entity of entities) {
entityList[entity.getType()].push(entity);
}
function handleSegmentChange(value?: string) {
if (!value) return;
const selected = window.location.href.match(/\/entity\/([^/]+)/)?.[1] || null;
switch (value) {
case "data":
if (selected) {
navigate(routes.data.entity.list(selected));
} else {
navigate(routes.data.root(), { absolute: true });
}
break;
case "schema":
if (selected) {
navigate(routes.data.schema.entity(selected));
} else {
navigate(routes.data.schema.root());
}
break;
}
}
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<SegmentedControl
data={[
{ value: "data", label: "Data" },
{ value: "schema", label: "Schema" }
]}
value={context}
onChange={handleSegmentChange}
/>
}
>
Entities
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow py-3 gap-3">
{/*<div className="pt-3 px-3">
<SearchInput placeholder="Search entities" />
</div>*/}
<EntityLinkList entities={entityList.regular} context={context} />
<EntityLinkList entities={entityList.system} context={context} title="System" />
<EntityLinkList
entities={entityList.generated}
context={context}
title="Generated"
/>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
const EntityLinkList = ({
entities,
title,
context
}: { entities: Entity[]; title?: string; context: "data" | "schema" }) => {
if (entities.length === 0) return null;
return (
<nav
className={twMerge(
"flex flex-col flex-1 gap-1 px-3",
title && "border-t border-primary/10 pt-2"
)}
>
{title && <div className="text-sm text-primary/50 ml-3.5 mb-1">{title}</div>}
{entities.map((entity) => {
const href =
context === "data"
? routes.data.entity.list(entity.name)
: routes.data.schema.entity(entity.name);
return (
<AppShell.SidebarLink key={entity.name} as={Link} href={href}>
{entity.label}
</AppShell.SidebarLink>
);
})}
</nav>
);
};
export function DataEmpty() {
useBrowserTitle(["Data"]);
const [navigate] = useNavigate();
function handleButtonClick() {
navigate(routes.settings.path(["data", "entities"]), { absolute: true });
}
return (
<Empty
Icon={IconDatabase}
title="No entity selected"
description="Please select an entity from the left sidebar or create a new one to continue."
buttonText="Create Entity"
buttonOnClick={handleButtonClick}
/>
);
}

View File

@@ -0,0 +1,296 @@
import { encodeSearch, ucFirst } from "core/utils";
import type { Entity, EntityData } from "data";
import type { EntityRelation } from "data";
import { Fragment, memo, useState } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { useClient } from "../../client";
import { useBknd } from "../../client";
import { Button } from "../../components/buttons/Button";
import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown";
import { useEntity } from "../../container";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { SectionHeaderLink } from "../../layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "../../layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "../../lib/routes";
import { bkndModals } from "../../modals";
export function DataEntityUpdate({ params }) {
const { app } = useBknd();
const entity = app.entity(params.entity as string)!;
const entityId = Number.parseInt(params.id as string);
const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
const targetRelations = app.relations.listableRelationsOf(entity);
console.log("targetRelations", targetRelations, app.relations.relationsOf(entity));
// filter out polymorphic for now
//.filter((r) => r.type() !== "poly");
const local_relation_refs = app.relations
.sourceRelationsOf(entity)
?.map((r) => r.other(entity).reference);
const container = useEntity(entity.name, entityId, {
fetch: {
query: {
with: local_relation_refs
}
}
});
function goBack(state?: Record<string, any>) {
window.history.go(-1);
}
async function onSubmitted(changeSet?: EntityData) {
console.log("update:changeSet", changeSet);
//return;
if (!changeSet) {
goBack();
return;
}
const res = await container.actions.update(changeSet);
console.log("update:res", res);
if (res.data?.error) {
setError(res.data.error);
} else {
error && setError(null);
goBack();
}
}
async function handleDelete() {
if (confirm("Are you sure to delete?")) {
const res = await container.actions.remove();
if (res.error) {
setError(res.error);
} else {
error && setError(null);
goBack();
}
}
}
const { Form, handleSubmit } = useEntityForm({
action: "update",
entity,
initialData: container.data,
onSubmitted
});
//console.log("form.data", Form.state.values, container.data);
const makeKey = (key: string | number = "") =>
`${params.entity.name}_${entityId}_${String(key)}`;
const fieldsDisabled =
container.raw.fetch?.isLoading ||
container.status.fetch.isUpdating ||
Form.state.isSubmitting;
return (
<Fragment key={makeKey()}>
<AppShell.SectionHeader
right={
<>
<Dropdown
position="bottom-end"
items={[
{
label: "Inspect",
onClick: () => {
bkndModals.open("debug", {
data: {
data: container.data as any,
entity: entity.toJSON(),
schema: entity.toSchema(true),
form: Form.state.values,
state: Form.state
}
});
}
},
{
label: "Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
})
},
{
label: "Delete",
onClick: handleDelete,
destructive: true,
disabled: fieldsDisabled
}
]}
>
<IconButton Icon={TbDots} />
</Dropdown>
<Form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button
type="button"
onClick={Form.handleSubmit}
variant="primary"
tabIndex={entity.fields.length + 1}
disabled={!canSubmit || isSubmitting || fieldsDisabled}
>
Update
</Button>
)}
/>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[
{ label: entity.label, href: routes.data.entity.list(entity.name) },
{ label: `Edit #${entityId}` }
]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable>
{error && (
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
<b className="mr-2">Update failed: </b> {error}
</div>
)}
<EntityForm
entity={entity}
entityId={entityId}
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={container.data ?? undefined}
Form={Form}
action="update"
className="flex flex-grow flex-col gap-3 p-3"
/>
{targetRelations.length > 0 ? (
<EntityDetailRelations id={entityId} entity={entity} relations={targetRelations} />
) : null}
</AppShell.Scrollable>
</Fragment>
);
}
function EntityDetailRelations({
id,
entity,
relations
}: {
id: number;
entity: Entity;
relations: EntityRelation[];
}) {
const [selected, setSelected] = useState<EntityRelation>(
// @ts-ignore
relations.length > 0 ? relations[0] : undefined
);
function handleClick(relation: EntityRelation) {
setSelected(relation);
}
if (relations.length === 0) {
return null;
}
//console.log("selected", selected, relations[0].helper(entity.name).other.reference);
return (
<div className="flex flex-col max-w-full">
<AppShell.SectionHeaderTabs
title="Relations"
items={relations.map((relation) => {
const other = relation.other(entity);
return {
as: "button",
type: "button",
label: ucFirst(other.reference),
onClick: () => handleClick(relation),
active: selected?.other(entity).reference === other.reference,
badge: relation.type()
};
})}
/>
<div className="flex flex-grow flex-col gap-3 p-3">
<EntityDetailInner id={id} entity={entity} relation={selected} />
</div>
</div>
);
}
function EntityDetailInner({
id,
entity,
relation
}: {
id: number;
entity: Entity;
relation: EntityRelation;
}) {
const other = relation.other(entity);
const client = useClient();
const [navigate] = useNavigate();
const search = {
select: other.entity.getSelect(undefined, "table"),
limit: 10,
offset: 0
};
const query = client
.query()
.data.entity(entity.name)
.readManyByReference(id, other.reference, other.entity.name, search);
function handleClickRow(row: Record<string, any>) {
navigate(routes.data.entity.edit(other.entity.name, row.id));
}
function handleClickNew() {
try {
const ref = relation.getReferenceQuery(other.entity, id, other.reference);
navigate(routes.data.entity.create(other.entity.name), {
query: ref.where
});
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
} catch (e) {
console.error("handleClickNew", e);
}
}
if (query.isPending) {
return null;
}
const isUpdating = query.isInitialLoading || query.isFetching;
//console.log("query", query, search.select);
return (
<div
data-updating={isUpdating ? 1 : undefined}
className="transition-opacity data-[updating]:opacity-50"
>
<EntityTable2
select={search.select}
data={query.data?.data ?? []}
entity={other.entity}
onClickRow={handleClickRow}
onClickNew={handleClickNew}
page={1}
/* @ts-ignore */
total={query.data?.body?.meta?.count ?? 1}
/*onClickPage={handleClickPage}*/
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Type } from "core/utils";
import { useState } from "react";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { useBknd } from "../../client/BkndProvider";
import { Button } from "../../components/buttons/Button";
import { type EntityData, useEntity } from "../../container";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import { useSearch } from "../../hooks/use-search";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "../../layouts/AppShell/Breadcrumbs2";
import { routes } from "../../lib/routes";
export function DataEntityCreate({ params }) {
const { app } = useBknd();
const entity = app.entity(params.entity as string)!;
const [error, setError] = useState<string | null>(null);
useBrowserTitle(["Data", entity.label, "Create"]);
const container = useEntity(entity.name);
// @todo: use entity schema for prefilling
const search = useSearch(Type.Object({}), {});
console.log("search", search.value);
function goBack(state?: Record<string, any>) {
window.history.go(-1);
}
async function onSubmitted(changeSet?: EntityData) {
console.log("create:changeSet", changeSet);
//return;
const res = await container.actions.create(changeSet);
console.log("create:res", res);
if (res.data?.error) {
setError(res.data.error);
} else {
error && setError(null);
// @todo: navigate to created?
goBack();
}
}
const { Form, handleSubmit, values } = useEntityForm({
action: "create",
entity,
initialData: search.value,
onSubmitted
});
const fieldsDisabled =
container.raw.fetch?.isLoading ||
container.status.fetch.isUpdating ||
Form.state.isSubmitting;
return (
<>
<AppShell.SectionHeader
right={
<>
<Button onClick={goBack}>Cancel</Button>
<Form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button
type="button"
onClick={Form.handleSubmit}
variant="primary"
tabIndex={entity.fields.length}
disabled={!canSubmit || isSubmitting}
>
Create
</Button>
)}
/>
</>
}
>
<Breadcrumbs2
path={[
{ label: entity.label, href: routes.data.entity.list(entity.name) },
{ label: "Create" }
]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
{error && (
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
<b className="mr-2">Create failed: </b> {error}
</div>
)}
<EntityForm
entity={entity}
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={search.value}
Form={Form}
action="create"
className="flex flex-grow flex-col gap-3 p-3"
/>
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,145 @@
import { Type } from "core/utils";
import { querySchema } from "data";
import { TbDots } from "react-icons/tb";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useBknd } from "../../client";
import { Button } from "../../components/buttons/Button";
import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown";
import { EntitiesContainer } from "../../container";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import { useSearch } from "../../hooks/use-search";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
// @todo: migrate to Typebox
const searchSchema = Type.Composite(
[
Type.Pick(querySchema, ["select", "where", "sort"]),
Type.Object({
page: Type.Optional(Type.Number({ default: 1 })),
perPage: Type.Optional(Type.Number({ default: 10 }))
})
],
{ additionalProperties: false }
);
export function DataEntityList({ params }) {
console.log("params", params);
const { app } = useBknd();
const entity = app.entity(params.entity as string)!;
const [navigate] = useNavigate();
const search = useSearch(searchSchema, {
select: entity.getSelect(undefined, "table"),
sort: entity.getDefaultSort()
});
console.log("search", search.value);
useBrowserTitle(["Data", entity.label]);
const PER_PAGE_OPTIONS = [5, 10, 25];
//console.log("search", search.value);
function handleClickRow(row: Record<string, any>) {
navigate(routes.data.entity.edit(entity.name, row.id));
}
function handleClickPage(page: number) {
search.set("page", page);
}
function handleSortClick(name: string) {
const sort = search.value.sort!;
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
// // @ts-expect-error - somehow all search keys are optional
console.log("new sort", newSort);
search.set("sort", newSort as any);
}
function handleClickPerPage(perPage: number) {
// @todo: also reset page to 1
search.set("perPage", perPage);
}
return (
<>
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
})
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Button
onClick={() => {
navigate(routes.data.entity.create(entity.name));
}}
variant="primary"
>
Create new
</Button>
</>
}
>
{entity.label}
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
<div className="flex flex-col flex-grow p-3 gap-3">
{/*<div className="w-64">
<SearchInput placeholder={`Filter ${entity.label}`} />
</div>*/}
<EntitiesContainer
entity={entity.name}
query={{
select: search.value.select,
limit: search.value.perPage,
offset: (search.value.page - 1) * search.value.perPage,
sort: search.value.sort
}}
>
{(params) => {
if (params.status.fetch.isLoading) {
return null;
}
const isUpdating = params.status.fetch.isUpdating;
return (
<div
data-updating={isUpdating ? 1 : undefined}
className="data-[updating]:opacity-50 transition-opacity pb-10"
>
<EntityTable2
data={params.data ?? []}
entity={entity}
select={search.value.select}
onClickRow={handleClickRow}
page={search.value.page}
sort={search.value.sort}
onClickSort={handleSortClick}
perPage={search.value.perPage}
perPageOptions={PER_PAGE_OPTIONS}
total={params.meta?.count}
onClickPage={handleClickPage}
onClickPerPage={handleClickPerPage}
/>
</div>
);
}}
</EntitiesContainer>
</div>
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,233 @@
import {
IconAlignJustified,
IconBolt,
IconCirclesRelation,
IconSettings
} from "@tabler/icons-react";
import { isDebug } from "core";
import type { Entity } from "data";
import { cloneDeep, omit } from "lodash-es";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { SectionHeaderAccordionItem } from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useGoBack, useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
export function DataSchemaEntity({ params }) {
const { $data } = useBkndData();
const [value, setValue] = useState("fields");
const fieldsRef = useRef<EntityFieldsFormRef>(null);
function toggle(value) {
return () => setValue(value);
}
const [navigate] = useNavigate();
const entity = $data.entity(params.entity as string)!;
return (
<>
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
})
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
<BasicSettings entity={entity} open={value === "2"} toggle={toggle("2")} />
<AppShell.SectionHeaderAccordionItem
title="Relations"
open={value === "3"}
toggle={toggle("3")}
ActiveIcon={IconCirclesRelation}
>
<Empty
title="Relations"
description="This will soon be available here. Meanwhile, check advanced settings."
buttonText="Advanced Settings"
buttonOnClick={() =>
navigate(routes.settings.path(["data", "relations"]), {
absolute: true
})
}
/>
</AppShell.SectionHeaderAccordionItem>
<AppShell.SectionHeaderAccordionItem
title="Indices"
open={value === "4"}
toggle={toggle("4")}
ActiveIcon={IconBolt}
>
<Empty
title="Indices"
description="This will soon be available here. Meanwhile, check advanced settings."
buttonText="Advanced Settings"
buttonOnClick={() =>
navigate(routes.settings.path(["data", "indices"]), {
absolute: true
})
}
/>
</AppShell.SectionHeaderAccordionItem>
</div>
</>
);
}
const Fields = ({
entity,
open,
toggle
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0);
const { actions } = useBkndData();
const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null);
async function handleUpdate() {
if (submitting) return;
setSubmitting(true);
const fields = ref.current?.getData()!;
await actions.entity.patch(entity.name).fields.set(fields);
setSubmitting(false);
setUpdates((u) => u + 1);
}
// @todo: the return of toJSON from Fields doesn't match "type" enum
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
return (
<AppShell.SectionHeaderAccordionItem
title="Fields"
open={open}
toggle={toggle}
ActiveIcon={IconAlignJustified}
renderHeaderRight={({ open }) =>
open ? (
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
Update
</Button>
) : null
}
>
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
{submitting && (
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
)}
<EntityFieldsForm fields={initialFields} ref={ref} key={String(updates)} sortable />
{isDebug() && (
<div>
<div className="flex flex-row gap-1 justify-center">
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
valid
</Button>
<Button size="small" onClick={() => setRes(ref.current?.getValues())}>
values
</Button>
<Button size="small" onClick={() => setRes(ref.current?.getData())}>
data
</Button>
<Button size="small" onClick={handleUpdate}>
update
</Button>
</div>
<pre className="select-text">{JSON.stringify(res, null, 2)}</pre>
</div>
)}
</div>
</AppShell.SectionHeaderAccordionItem>
);
};
const BasicSettings = ({
entity,
open,
toggle
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const d = useBkndData();
const config = d.entities?.[entity.name]?.config;
const formRef = useRef<JsonSchemaFormRef>(null);
const schema = cloneDeep(
// @ts-ignore
d.schema.properties.entities.additionalProperties?.properties?.config
);
const [_schema, _config] = extractSchema(schema as any, config, ["fields"]);
// set fields as enum
try {
// @ts-ignore
_schema.properties.sort_field.enum = entity.getFields().map((f) => f.name);
} catch (e) {
console.error("error setting sort_field enum", e);
}
async function handleUpdate() {
console.log("update", formRef.current?.formData());
await d.actions.entity.patch(entity.name).config(formRef.current?.formData());
}
return (
<AppShell.SectionHeaderAccordionItem
title="Settings"
open={open}
toggle={toggle}
ActiveIcon={IconSettings}
renderHeaderRight={({ open }) =>
open ? (
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
Update
</Button>
) : null
}
>
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
<JsonSchemaForm
ref={formRef}
schema={_schema}
formData={_config}
onSubmit={console.log}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</AppShell.SectionHeaderAccordionItem>
);
};

View File

@@ -0,0 +1,41 @@
import { Suspense, lazy, useRef } from "react";
import {
CreateModal,
type CreateModalRef
} from "ui/modules/data/components/schema/create-modal/CreateModal";
import { Button } from "../../components/buttons/Button";
import * as AppShell from "../../layouts/AppShell/AppShell";
const DataSchemaCanvas = lazy(() =>
import("ui/modules/data/components/canvas/DataSchemaCanvas").then((m) => ({
default: m.DataSchemaCanvas
}))
);
export function DataSchemaIndex() {
const createModalRef = useRef<CreateModalRef>(null);
return (
<>
<CreateModal ref={createModalRef} />
<AppShell.SectionHeader
right={
<Button
type="button"
variant="primary"
onClick={() => createModalRef.current?.open()}
>
Create new
</Button>
}
>
Schema Overview
</AppShell.SectionHeader>
<div className="w-full h-full">
<Suspense>
<DataSchemaCanvas />
</Suspense>
</div>
</>
);
}

View File

@@ -0,0 +1,454 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Tabs, TextInput, Textarea, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Type } from "@sinclair/typebox";
import {
type Static,
StringIdentifier,
objectCleanEmpty,
ucFirstAllSnakeToPascalWithSpaces
} from "core/utils";
import { Entity } from "data";
import {
type TAppDataEntityFields,
fieldsSchemaObject as originalFieldsSchemaObject
} from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { Button } from "ui";
import { useBknd } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
import { Popover } from "ui/components/overlay/Popover";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
const fieldsSchemaObject = originalFieldsSchemaObject;
const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
const fieldSchema = Type.Object({
name: StringIdentifier,
new: Type.Optional(Type.Boolean({ const: true })),
field: fieldsSchema
});
type TFieldSchema = Static<typeof fieldSchema>;
const schema = Type.Object({
fields: Type.Array(fieldSchema)
});
type TFieldsFormSchema = Static<typeof schema>;
const fieldTypes = Object.keys(fieldsSchemaObject);
const defaultType = fieldTypes[0];
const blank_field = { name: "", field: { type: defaultType, config: {} } } as TFieldSchema;
const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"];
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
//console.log("specificFieldSchema", type);
return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps);
}
export type EntityFieldsFormRef = {
getValues: () => TFieldsFormSchema;
getData: () => TAppDataEntityFields;
isValid: () => boolean;
reset: () => void;
};
export const EntityFieldsForm = forwardRef<
EntityFieldsFormRef,
{
fields: TAppDataEntityFields;
onChange?: (formData: TAppDataEntityFields) => void;
sortable?: boolean;
}
>(function EntityFieldsForm({ fields: _fields, sortable, ...props }, ref) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
name,
field
}));
/*const entityFields = entity.fields.map((field) => ({
name: field.name,
field: field.toJSON()
}));*/
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue,
setError,
reset,
clearErrors
} = useForm({
mode: "all",
resolver: typeboxResolver(schema),
defaultValues: {
fields: entityFields
} as TFieldsFormSchema
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "fields"
});
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
return Object.fromEntries(
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
);
}
useEffect(() => {
if (props?.onChange) {
console.log("----set");
watch((data: any) => {
console.log("---calling");
props?.onChange?.(toCleanValues(data));
});
}
//props?.onChange?.()
}, []);
useImperativeHandle(ref, () => ({
reset,
getValues: () => getValues(),
getData: () => {
return toCleanValues(getValues());
/*return Object.fromEntries(
getValues().fields.map((field) => [field.name, objectCleanEmpty(field.field)])
);*/
},
isValid: () => isValid
}));
console.log("errors", errors.fields);
/*useEffect(() => {
console.log("change", values);
onSubmit(values);
}, [values]);*/
function onSubmit(data: TFieldsFormSchema) {
console.log("submit", isValid, data, errors);
}
function onSubmitInvalid(a, b) {
console.log("submit invalid", a, b);
}
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
const newField = {
name: "",
new: true,
field: {
type: _type,
config: {}
}
};
console.log("handleAppend", _type, newField);
append(newField);
}
const formProps = {
watch,
register,
setValue,
getValues,
control,
setError
};
return (
<>
<form
onSubmit={handleSubmit(onSubmit as any, onSubmitInvalid)}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{sortable ? (
<SortableList
data={fields}
key={fields.length}
onReordered={move}
extractId={(item) => item.id}
disableIndices={[0]}
renderItem={({ dnd, ...props }, index) => (
<EntityFieldMemo
key={props.id}
field={props as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
dnd={dnd}
/>
)}
/>
) : (
<div>
{fields.map((field, index) => (
<EntityField
key={field.id}
field={field as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
/>
))}
</div>
)}
<Popover
className="flex flex-col w-full"
target={({ toggle }) => (
<SelectType
onSelect={(type) => {
handleAppend(type as any);
toggle();
}}
/>
)}
>
<Button className="justify-center">Add Field</Button>
</Popover>
</div>
</div>
<button type="submit" className="hidden" />
{/*<Debug watch={watch} errors={errors} />*/}
</form>
</>
);
});
const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
const types = fieldSpecs.filter((s) => s.addable !== false);
return (
<div className="flex flex-row gap-2 justify-center flex-wrap">
{types.map((type) => (
<Button
key={type.type}
IconLeft={type.icon}
variant="ghost"
onClick={() => onSelect(type.type)}
>
{type.label}
</Button>
))}
</div>
);
};
const Debug = ({ watch, errors }) => {
return (
<div>
<div>
{Object.entries(errors).map(([key, value]) => (
<p key={key}>
{/* @ts-ignore */}
{key}: {value.message}
</p>
))}
</div>
<pre>{JSON.stringify(watch(), null, 2)}</pre>
</div>
);
};
const EntityFieldMemo = memo(EntityField, (prev, next) => {
return prev.field.id !== next.field.id;
});
function EntityField({
field,
index,
form: { watch, register, setValue, getValues, control, setError },
remove,
errors,
dnd
}: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number;
form: Pick<
UseFormReturn<any>,
"watch" | "register" | "setValue" | "getValues" | "control" | "setError"
>;
remove: (index: number) => void;
errors: any;
dnd?: SortableItemProps;
}) {
const [opened, handlers] = useDisclosure(false);
const prefix = `fields.${index}.field` as const;
const type = field.field.type;
const name = watch(`fields.${index}.name`);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || [];
const hidden = fieldSpec.hidden || [];
const dragDisabled = index === 0;
const hasErrors = !!errors?.fields?.[index];
function handleDelete(index: number) {
return () => {
if (name.length === 0) {
remove(index);
return;
}
window.confirm(`Sure to delete "${name}"?`) && remove(index);
};
}
//console.log("register", register(`${prefix}.config.required`));
const dndProps = dnd ? { ...dnd.provided.draggableProps, ref: dnd.provided.innerRef } : {};
return (
<div
key={field.id}
className={twMerge(
"flex flex-col border border-muted rounded bg-background mb-2",
opened && "mb-6",
hasErrors && "border-red-500 "
)}
{...dndProps}
>
<div className="flex flex-row gap-2 px-2 py-2">
{dnd ? (
<div className="flex items-center" {...dnd.provided.dragHandleProps}>
<IconButton Icon={TbGripVertical} className="mt-1" disabled={dragDisabled} />
</div>
) : null}
<div className="flex flex-row flex-grow gap-4 items-center md:mr-6">
<Tooltip label={fieldSpec.label}>
<div className="flex flex-row items-center p-2 bg-primary/5 rounded">
<fieldSpec.icon className="size-5" />
</div>
</Tooltip>
{field.new ? (
<TextInput
error={!!errors?.fields?.[index]?.name.message}
placeholder="Enter a property name..."
classNames={{
root: "w-full h-full",
wrapper: "font-mono h-full",
input: "pt-px !h-full"
}}
{...register(`fields.${index}.name`)}
disabled={!field.new}
/>
) : (
<div className="font-mono flex-grow flex flex-row gap-3">
<span>{name}</span>
{field.field.config?.label && (
<span className="opacity-50">{field.field.config?.label}</span>
)}
</div>
)}
<div className="flex-col gap-1 hidden md:flex">
<span className="text-xs text-primary/50 leading-none">Required</span>
<MantineSwitch size="sm" name={`${prefix}.config.required`} control={control} />
</div>
</div>
<div className="flex items-end">
<div className="flex flex-row gap-4">
<IconButton
size="lg"
Icon={TbSettings}
iconProps={{ strokeWidth: 1.5 }}
onClick={handlers.toggle}
variant={opened ? "primary" : "ghost"}
/>
</div>
</div>
</div>
{opened && (
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
<Tabs defaultValue="general">
<Tabs.List className="flex flex-row">
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="specific">{ucFirstAllSnakeToPascalWithSpaces(type)}</Tabs.Tab>
<Tabs.Tab value="visibility" disabled>
Visiblity
</Tabs.Tab>
<div className="flex flex-grow" />
<Tabs.Tab value="code" className="!self-end">
Code
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<div className="flex flex-col gap-2 pt-3 pb-1" key={`${prefix}_${type}`}>
<div className="flex flex-row">
<MantineSwitch
label="Required"
name={`${prefix}.config.required`}
control={control}
/>
</div>
<TextInput
label="Label"
placeholder="Label"
{...register(`${prefix}.config.label`)}
/>
<Textarea
label="Description"
placeholder="Description"
{...register(`${prefix}.config.description`)}
/>
{!hidden.includes("virtual") && (
<MantineSwitch
label="Virtual"
name={`${prefix}.config.virtual`}
control={control}
disabled={disabled.includes("virtual")}
/>
)}
</div>
</Tabs.Panel>
<Tabs.Panel value="specific">
<div className="flex flex-col gap-2 pt-3 pb-1">
<JsonSchemaForm
key={type}
schema={specificFieldSchema(type as any)}
formData={specificData}
uiSchema={dataFieldsUiSchema.config}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={(value) => {
setValue(`${prefix}.config`, {
...getValues([`fields.${index}.config`])[0],
...value
});
}}
/>
</div>
</Tabs.Panel>
<Tabs.Panel value="code">
{(() => {
const { id, ...json } = field;
return <JsonViewer json={json} expand={4} />;
})()}
</Tabs.Panel>
<div className="flex flex-row justify-end">
<Button
IconLeft={TbTrash}
onClick={handleDelete(index)}
size="small"
variant="subtlered"
>
Delete
</Button>
</div>
</Tabs>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { Route, Switch } from "wouter";
import { DataEmpty, DataRoot } from "./_data.root";
import { DataEntityUpdate } from "./data.$entity.$id";
import { DataEntityCreate } from "./data.$entity.create";
import { DataEntityList } from "./data.$entity.index";
import { DataSchemaEntity } from "./data.schema.$entity";
import { DataSchemaIndex } from "./data.schema.index";
export default function DataRoutes() {
return (
<DataRoot>
<Switch>
<Route path="/" component={DataEmpty} />
<Route path="/entity/:entity" component={DataEntityList} />
<Route path="/entity/:entity/create" component={DataEntityCreate} />
<Route path="/entity/:entity/edit/:id" component={DataEntityUpdate} />
<Route path="/schema" nest>
<Route path="/" component={DataSchemaIndex} />
<Route path="/entity/:entity" component={DataSchemaEntity} />
</Route>
</Switch>
</DataRoot>
);
}

View File

@@ -0,0 +1,67 @@
import { IconHierarchy2 } from "@tabler/icons-react";
import { isDebug } from "core";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "../../client/BkndProvider";
import { IconButton } from "../../components/buttons/IconButton";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes } from "../../lib/routes";
const ComingSoon = () => (
<span className="text-xs bg-primary/10 flex rounded-full px-2.5 py-1 leading-none">
coming soon
</span>
);
export function FlowsRoot(props) {
const debug = isDebug();
//const debug = false;
return debug ? <FlowsActual {...props} /> : <FlowsEmpty />;
}
export function FlowsActual({ children }) {
const { app } = useBknd();
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["flows"])}>
<IconButton Icon={TbSettings} />
</Link>
}
>
Flows
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href={routes.flows.flows.list()}>
All Flows
</AppShell.SidebarLink>
<AppShell.SidebarLink disabled className="justify-between">
Endpoints
<ComingSoon />
</AppShell.SidebarLink>
<AppShell.SidebarLink disabled className="justify-between">
Executions
<ComingSoon />
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={app.getSettingsPath(["flows"])}>
Settings
</AppShell.SidebarLink>
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
export function FlowsEmpty() {
useBrowserTitle(["Flows"]);
return <Empty Icon={IconHierarchy2} title="Flows" description="Flows are coming very soon!" />;
}

View File

@@ -0,0 +1,136 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput } from "@mantine/core";
import { useFocusTrap } from "@mantine/hooks";
import { TypeRegistry } from "@sinclair/typebox";
import {
type Static,
StringEnum,
StringIdentifier,
Type,
registerCustomTypeboxKinds
} from "core/utils";
import { TRIGGERS } from "flows/flows-schema";
import { forwardRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useFlows } from "ui/client/schema/flows/use-flows";
import { MantineSegmentedControl } from "ui/components/form/hook-form-mantine/MantineSegmentedControl";
import {
Modal2,
type Modal2Ref,
ModalBody,
ModalFooter,
ModalTitle
} from "../../../components/modal/Modal2";
import { Step, Steps, useStepContext } from "../../../components/steps/Steps";
registerCustomTypeboxKinds(TypeRegistry);
export type TCreateFlowModalSchema = any;
const triggerNames = Object.keys(TRIGGERS) as unknown as (keyof typeof TRIGGERS)[];
const schema = Type.Object({
name: StringIdentifier,
trigger: StringEnum(triggerNames),
mode: StringEnum(["async", "sync"])
});
export const FlowCreateModal = forwardRef<Modal2Ref>(function FlowCreateModal(props, ref) {
const [path, setPath] = useState<string[]>([]);
function close() {
// @ts-ignore
ref?.current?.close();
}
return (
<Modal2 ref={ref} size="lg">
<Steps path={path} lastBack={close}>
<Step id="select">
<ModalTitle path={["Create New Flow"]} onClose={close} />
<StepCreate />
</Step>
</Steps>
</Modal2>
);
});
export function StepCreate() {
const focusTrapRef = useFocusTrap();
const { actions } = useFlows();
const { nextStep, stepBack, state, setState } = useStepContext<TCreateFlowModalSchema>();
const {
handleSubmit,
watch,
control,
register,
formState: { isValid, errors }
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: {
name: "",
trigger: "manual",
mode: "async"
},
mode: "onSubmit"
});
async function onSubmit(data: Static<typeof schema>) {
console.log(data, isValid);
actions.flow.create(data.name, {
trigger: {
type: data.trigger,
config: {
mode: data.mode
}
}
});
}
console.log("errors", errors);
return (
<form ref={focusTrapRef} onSubmit={handleSubmit(onSubmit as any)}>
<ModalBody className="min-h-40">
<div>
<TextInput
data-autofocus
label="Flow Name"
placeholder="Enter flow name"
error={errors.name?.message as any}
{...register("name", { required: true })}
/>
</div>
<div className="grid grid-cols-2 gap-6">
<MantineSegmentedControl
label="Trigger Type"
name="trigger"
data={[
{ label: "Manual", value: "manual" },
{ label: "HTTP", value: "http" },
{ label: "Event", value: "event" }
]}
control={control}
/>
<MantineSegmentedControl
label="Execution mode"
name="mode"
data={[
{ label: "Async", value: "async" },
{ label: "Sync", value: "sync" }
]}
control={control}
/>
</div>
<pre>{JSON.stringify(watch(), null, 2)}</pre>
</ModalBody>
<ModalFooter
next={{
type: "submit",
disabled: !isValid
}}
nextLabel="Create"
prev={{ onClick: stepBack }}
prevLabel="Cancel"
/>
</form>
);
}

View File

@@ -0,0 +1,241 @@
import { Slider, Tabs } from "@mantine/core";
import { MarkerType, ReactFlowProvider } from "@xyflow/react";
import { objectDepth } from "core/utils";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { pick, throttle } from "lodash-es";
import { useEffect, useRef, useState } from "react";
import { TbArrowLeft } from "react-icons/tb";
import { Canvas } from "ui/components/canvas/Canvas";
import { Panels } from "ui/components/canvas/panels";
import { Panel } from "ui/components/canvas/panels/Panel";
import { nodeTypes } from "ui/modules/flows/components2/nodes";
import {
FlowCanvasProvider,
flowToEdges,
flowToNodes,
useFlowCanvas,
useFlowCanvasState,
useFlowSelector
} from "ui/modules/flows/hooks/use-flow";
import { JsonViewer } from "../../components/code/JsonViewer";
import { routes, useGoBack, useNavigate } from "../../lib/routes";
/**
* @todo: AppFlows config must be updated to have fixed ids per task and connection
* ideally in array format
*
*/
export function FlowsEdit(props) {
return (
<FlowCanvasProvider name={props.params.flow}>
<ReactFlowProvider>
<FlowsEditInner />
</ReactFlowProvider>
</FlowCanvasProvider>
);
}
function FlowsEditInner() {
const ref = useRef<HTMLDivElement>(null);
const [rect, setRect] = useState<DOMRect>();
const $flow = useFlowCanvas();
if (!$flow.data || !$flow.name) return "no flow";
useEffect(() => {
// get width and height of ref object
console.log("ref", ref.current?.getBoundingClientRect());
setRect(ref.current?.getBoundingClientRect());
}, []);
const nodes = flowToNodes($flow.data, $flow.name);
const edges = flowToEdges($flow.data) as any;
console.log("nodes", nodes);
console.log("edges", edges);
const triggerHeight = 260;
const offset = 50;
const viewport = {
zoom: 1,
x: rect?.width ? rect.width * 0.1 : 0,
y: rect?.height ? rect.height / 2 - triggerHeight / 2 - offset : 0
};
return (
<div className="flex flex-col w-full h-full" ref={ref}>
{rect && (
<>
<Canvas
externalProvider
backgroundStyle="dots"
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodeDragThreshold={0}
fitView={false}
defaultViewport={viewport}
nodesConnectable={true}
onDropNewNode={(node) => ({
...node,
type: "select",
data: { label: "" }
})}
onDropNewEdge={(edge) => ({
...edge,
style: {
strokeWidth: 2
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 10,
height: 10
}
})}
>
<FlowPanels />
</Canvas>
<Debugger />
</>
)}
</div>
);
}
function FlowPanels() {
const state = useFlowSelector((s) => pick(s, ["name", "dirty"]));
const [navigate] = useNavigate();
const { goBack } = useGoBack(() => navigate(routes.flows.flows.list()));
return (
<Panels minimap zoom>
<Panel position="top-left" className="gap-2 pr-6">
<Panel.IconButton Icon={TbArrowLeft} round onClick={goBack} />
<Panel.Text>
{state.name} {state.dirty ? "*" : ""}
</Panel.Text>
</Panel>
</Panels>
);
}
/*
function PanelsOld() {
//console.log("Panels");
const state = useFlowSelector((s) => pick(s, ["name", "dirty"]));
const [navigate] = useNavigate();
const { goBack } = useGoBack(() => navigate(routes.flows.flows.list()));
const [minimap, setMinimap] = useState(false);
const reactFlow = useReactFlow();
const { zoom, x, y } = useViewport();
const percent = Math.round(zoom * 100);
const handleZoomIn = async () => await reactFlow.zoomIn();
const handleZoomReset = async () => reactFlow.zoomTo(1);
const handleZoomOut = async () => await reactFlow.zoomOut();
function toggleMinimap() {
setMinimap((p) => !p);
}
return (
<>
<FlowPanel position="top-left" className="gap-2 pr-6">
<FlowPanel.IconButton Icon={TbArrowLeft} round onClick={goBack} />
<FlowPanel.Text>
{state.name} {state.dirty ? "*" : ""}
</FlowPanel.Text>
</FlowPanel>
<FlowPanel position="bottom-center">
<FlowPanel.Text className="px-2" mono>
{x.toFixed(2)},{y.toFixed(2)}
</FlowPanel.Text>
</FlowPanel>
<FlowPanel unstyled position="bottom-right">
<FlowPanel.Wrapper className="px-1.5">
<FlowPanel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
<FlowPanel.Text className="px-2" mono onClick={handleZoomReset}>
{percent}%
</FlowPanel.Text>
<FlowPanel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
<FlowPanel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
</FlowPanel.Wrapper>
<FlowPanel.Wrapper>
<FlowPanel.IconButton
Icon={minimap ? TbSitemap : TbSitemap}
round
onClick={toggleMinimap}
variant={minimap ? "default" : "ghost"}
/>
</FlowPanel.Wrapper>
{minimap && <MiniMap style={{ bottom: 50, right: -5 }} ariaLabel={null} />}
</FlowPanel>
</>
);
}*/
type DebuggerTabProps = {
tab: string | null;
store?: Record<string, any>;
};
const debuggerTabAtom = atomWithStorage<DebuggerTabProps>("__dev_flow_debugger_tab", { tab: null });
const Debugger = () => {
const [_state, _setState] = useAtom(debuggerTabAtom);
const $flow = useFlowCanvas();
const state = useFlowCanvasState();
function handleTabChange(tab: string | null) {
_setState((prev) => ({ ...prev, tab: prev.tab === tab ? null : tab }));
}
const expand = _state.store?.expand || 3;
return (
<div className="flex fixed left-5 bottom-5 z-20">
<Tabs value={_state.tab} onChange={handleTabChange}>
<div className="max-h-96 overflow-y-scroll bg-background/70">
<Tabs.Panel value="store">
<div className="flex flex-row text-sm">
<JsonViewer
className="max-w-96 break-all"
title="Context"
json={{
name: $flow.name,
...$flow.data
}}
expand={expand}
/>
<JsonViewer
className="max-w-96 break-all"
title="State"
json={{
name: state.name,
...state.flow
}}
expand={expand}
/>
</div>
<Slider
className="w-36"
defaultValue={expand}
min={0}
max={objectDepth(state.flow ?? {})}
onChange={throttle(
(n) =>
_setState((prev) => ({
...prev,
store: { ...prev.store, expand: n }
})),
250
)}
/>
</Tabs.Panel>
</div>
<Tabs.List>
<Tabs.Tab value="store">Store</Tabs.Tab>
</Tabs.List>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useRef } from "react";
import { TbTrash } from "react-icons/tb";
import { useFlows } from "../../client/schema/flows/use-flows";
import { Button } from "../../components/buttons/Button";
import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
import { FlowCreateModal, type TCreateFlowModalSchema } from "./components/FlowCreateModal";
export function FlowsList() {
const createModalRef = useRef<TCreateFlowModalSchema>(null);
const [navigate] = useNavigate();
const { flows } = useFlows();
console.log("flows", flows);
const data = flows.map((flow) => ({
flow: flow.name,
trigger: flow.trigger.type,
mode: flow.trigger.config.mode,
tasks: Object.keys(flow.tasks).length,
start_task: flow.startTask?.name
}));
function handleClick(row) {
navigate(routes.flows.flows.edit(row.flow));
}
return (
<>
<FlowCreateModal ref={createModalRef} />
<AppShell.SectionHeader
right={
<Button variant="primary" onClick={() => createModalRef.current?.open()}>
Create new
</Button>
}
>
All Flows
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-3">
<DataTable
data={data}
renderValue={renderValue}
renderHeader={ucFirstAllSnakeToPascalWithSpaces}
onClickRow={handleClick}
/>
</div>
</AppShell.Scrollable>
</>
);
}
const renderValue = ({ value, property }) => {
if (["is_default", "implicit_allow"].includes(property)) {
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
}
if (property === "permissions") {
return [...(value || [])].map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
));
}
return <CellValue value={value} property={property} />;
};

View File

@@ -0,0 +1,15 @@
import { Route, Switch } from "wouter";
import { FlowsRoot } from "./_flows.root";
import { FlowsEdit } from "./flows.edit.$name";
import { FlowsList } from "./flows.list";
export default function FlowsRoutes() {
return (
<Switch>
<Route path="/flow/:flow" component={FlowsEdit} />
<FlowsRoot>
<Route path="/" component={FlowsList} />
</FlowsRoot>
</Switch>
);
}

View File

@@ -0,0 +1,64 @@
import { IconHierarchy2 } from "@tabler/icons-react";
import { ReactFlowProvider } from "@xyflow/react";
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { TbSettings } from "react-icons/tb";
import { useLocation } from "wouter";
import { useBknd } from "../../client/BkndProvider";
import { useFlows } from "../../client/schema/flows/use-flows";
import { IconButton } from "../../components/buttons/IconButton";
import { Empty } from "../../components/display/Empty";
import { SearchInput } from "../../components/form/SearchInput";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function FlowsRoot({ children }) {
return <ReactFlowProvider>{children}</ReactFlowProvider>;
}
export function FlowsEmpty() {
const { app } = useBknd();
useBrowserTitle(["Flows"]);
const [, navigate] = useLocation();
const { flows } = useFlows();
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["flows"])}>
<IconButton Icon={TbSettings} />
</Link>
}
>
Flows
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<div>
<SearchInput placeholder="Search flows" />
</div>
<nav className="flex flex-col flex-1 gap-1">
{flows.map((flow) => (
<AppShell.SidebarLink key={flow.name} as={Link} href={`/flow/${flow.name}`}>
{ucFirstAllSnakeToPascalWithSpaces(flow.name)}
</AppShell.SidebarLink>
))}
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">
<Empty
Icon={IconHierarchy2}
title="No flow selected"
description="Please select a flow from the left sidebar or create a new one
to continue."
buttonText="Create Flow"
buttonOnClick={() => navigate(app.getSettingsPath(["flows"]))}
/>
</main>
</>
);
}

View File

@@ -0,0 +1,202 @@
import { type Edge, type Node, useOnSelectionChange } from "@xyflow/react";
import { type Execution, ExecutionState, type Flow, type Task } from "flows";
import { useEffect, useState } from "react";
import {
TbArrowLeft,
TbChevronDown,
TbChevronUp,
TbDots,
TbPlayerPlayFilled,
TbSettings
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import FlowCanvas from "ui/modules/flows/components/FlowCanvas";
import { TaskForm } from "ui/modules/flows/components/form/TaskForm";
import { useLocation } from "wouter";
import { useBknd } from "../../client/BkndProvider";
import { Button } from "../../components/buttons/Button";
import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown";
import { useFlow } from "../../container/use-flows";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { SectionHeader } from "../../layouts/AppShell/AppShell";
export function FlowEdit({ params }) {
const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig();
const { basepath } = app.getAdminConfig();
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const [location, navigate] = useLocation();
const [execution, setExecution] = useState<Execution>();
const [selectedNodes, setSelectedNodes] = useState<Node[]>([]);
const [selectedEdges, setSelectedEdges] = useState<Edge[]>([]);
console.log("key", params, params.flow);
const { flow } = useFlow(params.flow);
console.log("--flow", flow);
async function handleRun() {
console.log("Running flow", flow);
const execution = flow.createExecution();
setExecution(execution);
// delay a bit before starting
execution.emgr.onEvent(
ExecutionState,
async (event) => {
if (event.params.state === "started") {
await new Promise((resolve) => setTimeout(resolve, 100));
}
},
"sync"
);
execution.subscribe(async (event) => {
console.log("[event]", event);
});
await new Promise((resolve) => setTimeout(resolve, 100));
execution.start();
}
function goBack(state?: Record<string, any>) {
window.history.go(-1);
}
useOnSelectionChange({
onChange: ({ nodes, edges }) => {
setSelectedNodes(nodes);
setSelectedEdges(edges);
}
});
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<a href="#" className="link p-1 rounded-md hover:bg-primary/5 flex items-center">
<TbSettings size={20} />
</a>
}
>
Tasks
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<Sidebar edges={selectedEdges} nodes={selectedNodes} flow={flow} />
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">
<SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Settings",
onClick: () => navigate(`${prefix}/flows/flows/${flow.name}`)
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Button variant="primary" IconLeft={TbPlayerPlayFilled} onClick={handleRun}>
Run
</Button>
</>
}
className="pl-3"
>
<AppShell.SectionHeaderTitle className="flex flex-row items-center gap-2">
<IconButton
onClick={goBack}
Icon={TbArrowLeft}
variant="default"
size="lg"
className="mr-1"
/>
<div className="truncate">
<span className="text-primary/60">Flow / </span>
{flow.name}
</div>
</AppShell.SectionHeaderTitle>
</SectionHeader>
<div className="w-full h-full">
<FlowCanvas flow={flow} execution={execution} options={{ theme }} key={theme} />
</div>
</main>
</>
);
}
function Sidebar({ nodes, edges, flow }: { flow: Flow; nodes: Node[]; edges: Edge[] }) {
const selectedNode = nodes?.[0];
// @ts-ignore
const selectedTask: Task | undefined = selectedNode?.data?.task;
useEffect(() => {
console.log("-- selected", selectedTask);
}, [selectedTask]);
const tasks = flow.getSequence().flat();
const Header = ({ onClick, opened, task }) => (
<div
className={twMerge(
"flex flex-row pl-5 pr-3 py-3 border-muted border-b cursor-pointer justify-between items-center font-bold",
opened && "bg-primary/5"
)}
onClick={onClick}
>
{task.name}
{opened ? <TbChevronUp size={18} /> : <TbChevronDown size={18} />}
</div>
);
return (
<div className="flex flex-col flex-grow">
{tasks.map((task) => {
const open = task.name === selectedTask?.name;
return (
<Collapsible
key={task.name}
className="flex flex-col"
header={(props) => <Header {...props} task={task} />}
open={open}
>
<div className="flex flex-col pl-5 pr-3 py-3">
<TaskForm task={task} onChange={console.log} />
</div>
</Collapsible>
);
})}
</div>
);
}
type CollapsibleProps = {
header: (props: any) => any;
className?: string;
children: React.ReactNode;
open?: boolean;
};
function Collapsible({ header, children, open = false, className }: CollapsibleProps) {
const [opened, setOpened] = useState(open);
function toggle() {
setOpened((prev) => !prev);
}
useEffect(() => {
setOpened(open);
}, [open]);
return (
<div className={twMerge("flex flex-col", className)}>
{header?.({ onClick: toggle, opened })}
{opened && children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Route } from "wouter";
import { FlowsEmpty, FlowsRoot } from "./_flows.root";
import { FlowEdit } from "./flow.$key";
export default function FlowRoutes() {
return (
<FlowsRoot>
<Route path="/" component={FlowsEmpty} />
<Route path="/flow/:flow" component={FlowEdit} />
</FlowsRoot>
);
}

View File

@@ -0,0 +1,81 @@
import { Suspense, lazy } from "react";
import { Route, Router, Switch } from "wouter";
import { useBknd } from "../client/BkndProvider";
import { AuthLogin } from "./auth/auth.login";
import { Root, RootEmpty } from "./root";
const DataRoutes = lazy(() => import("./data"));
const AuthRoutes = lazy(() => import("./auth"));
const MediaRoutes = lazy(() => import("./media"));
const FlowRoutes = lazy(() => import("./flows"));
const SettingsRoutes = lazy(() => import("./settings"));
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));
export function Routes() {
const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig();
const { basepath } = app.getAdminConfig();
return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
<Router base={basepath}>
<Switch>
<Route path="/auth/login" component={AuthLogin} />
<Route path="/" nest>
<Root>
<Switch>
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
<Route path="/" component={RootEmpty} />
<Route path="/data" nest>
<Suspense fallback={null}>
<DataRoutes />
</Suspense>
</Route>
<Route path="/flows" nest>
<Suspense fallback={null}>
<FlowRoutes />
</Suspense>
</Route>
<Route path="/auth" nest>
<Suspense fallback={null}>
<AuthRoutes />
</Suspense>
</Route>
<Route path="/media" nest>
<Suspense fallback={null}>
<MediaRoutes />
</Suspense>
</Route>
<Route path="/settings" nest>
<Suspense fallback={null}>
<SettingsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>
</Root>
</Route>
</Switch>
</Router>
</div>
);
}
function NotFound() {
return (
<div className="flex w-full items-center justify-center">
<p className="text-2xl font-mono">
<span className="font-bold">404</span>
<span className="opacity-50">, Sorry :)</span>
</p>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { IconPhoto } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { Dropzone } from "ui/modules/media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "ui/modules/media/helper";
import { useLocation } from "wouter";
import { useClient } from "../../client";
import { useBknd } from "../../client/BkndProvider";
import { IconButton } from "../../components/buttons/IconButton";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import { useEvent } from "../../hooks/use-event";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function MediaRoot({ children }) {
const { app, config } = useBknd();
const [, navigate] = useLocation();
useBrowserTitle(["Media"]);
if (!config.media.enabled) {
return (
<Empty
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
buttonText="Manage Settings"
buttonOnClick={() => navigate(app.getSettingsPath(["media"]))}
/>
);
}
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["media"])}>
<IconButton Icon={TbSettings} />
</Link>
}
>
Media
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
{/*<div>
<SearchInput placeholder="Search buckets" />
</div>*/}
<nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href="/media" className="active">
Main Bucket
</AppShell.SidebarLink>
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main>
</>
);
}
// @todo: add infinite load
export function MediaEmpty() {
useBrowserTitle(["Media"]);
const client = useClient();
const query = client.media().list({ limit: 50 });
const getUploadInfo = useEvent((file) => {
const api = client.media().api();
return {
url: api.getFileUploadUrl(file),
headers: api.getUploadHeaders(),
method: "POST"
};
});
const handleDelete = useEvent(async (file) => {
return await client.media().deleteFile(file);
});
const media = query.data?.data || [];
const initialItems = mediaItemsToFileStates(media, { baseUrl: client.baseUrl });
console.log("initialItems", initialItems);
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Dropzone
key={query.isSuccess ? "loaded" : "initial"}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={initialItems}
/>
</div>
</AppShell.Scrollable>
);
}

View File

@@ -0,0 +1,10 @@
import { Route } from "wouter";
import { MediaEmpty, MediaRoot } from "./_media.root";
export default function MediaRoutes() {
return (
<MediaRoot>
<Route path="/" component={MediaEmpty} />
</MediaRoot>
);
}

View File

@@ -0,0 +1,52 @@
import { IconFingerprint, IconHome } from "@tabler/icons-react";
import { isDebug } from "core";
import { Suspense, lazy, useEffect } from "react";
import { useAuth } from "ui";
import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title";
import * as AppShell from "../layouts/AppShell/AppShell";
import { useNavigate } from "../lib/routes";
// @todo: package is still required somehow
const ReactQueryDevtools = (p: any) => null; /*!isDebug()
? () => null // Render nothing in production
: lazy(() =>
import("@tanstack/react-query-devtools").then((res) => ({
default: res.ReactQueryDevtools,
})),
);*/
export const Root = ({ children }) => {
const { verify } = useAuth();
useEffect(() => {
verify();
}, []);
return (
<AppShell.Root>
<AppShell.Header />
<AppShell.Content>{children}</AppShell.Content>
<Suspense>
<ReactQueryDevtools buttonPosition="bottom-left" />
</Suspense>
</AppShell.Root>
);
};
export function RootEmpty() {
const [navigate] = useNavigate();
useEffect(() => {
navigate("/data");
}, []);
useBrowserTitle();
return (
<Empty
Icon={IconHome}
title="Not implemented yet"
description={`Go checkout "Data" or "Media" for some action.`}
/>
);
}

View File

@@ -0,0 +1,358 @@
import { useHotkeys } from "@mantine/hooks";
import { type TObject, ucFirst } from "core/utils";
import { omit } from "lodash-es";
import { type ReactNode, useMemo, useRef, useState } from "react";
import { TbSettings } from "react-icons/tb";
import { useAuth } from "ui";
import { Link, Route, useLocation } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { Empty } from "../../../components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown";
import { DataTable } from "../../../components/table/DataTable";
import { useEvent } from "../../../hooks/use-event";
import * as AppShell from "../../../layouts/AppShell/AppShell";
import { SectionHeaderTabs } from "../../../layouts/AppShell/AppShell";
import { Breadcrumbs } from "../../../layouts/AppShell/Breadcrumbs";
import { extractSchema } from "../utils/schema";
import { SettingNewModal, type SettingsNewModalProps } from "./SettingNewModal";
import { SettingSchemaModal, type SettingsSchemaModalRef } from "./SettingSchemaModal";
export type SettingProps<
Schema extends TObject = TObject,
Props = Schema extends TObject<infer TProperties> ? TProperties : any
> = {
schema: Schema;
config: any;
prefix?: string;
path?: string[];
uiSchema?: any;
options?: {
allowDelete?: (config: any) => boolean;
allowEdit?: (config: any) => boolean;
showAlert?: (config: any) => string | ReactNode | undefined;
};
properties?: {
[key in keyof Partial<Props>]: {
extract?: boolean;
hide?: boolean;
new?: Pick<SettingsNewModalProps, "schema" | "generateKey" | "uiSchema" | "anyOfValues">;
tableValues?: (config: any) => Record<string, any>[];
};
};
};
export function Setting<Schema extends TObject = any>({
schema,
uiSchema,
config,
prefix = "/",
options,
path = [],
properties
}: SettingProps<Schema>) {
const [submitting, setSubmitting] = useState(false);
const { actions } = useBknd();
const formRef = useRef<JsonSchemaFormRef>(null);
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
const [editing, setEditing] = useState(false);
useHotkeys([
[
"mod+s",
(e) => {
e.preventDefault();
onSave();
return false;
}
],
[
"e",
() => {
if (!editing) {
onToggleEdit();
}
}
],
[
"Escape",
() => {
if (editing) {
onToggleEdit();
}
}
]
]);
const exclude = useMemo(
() =>
properties
? Object.entries(properties)
.filter(([, value]) => value.hide || value.extract)
.map(([key]) => key)
: [],
[properties]
);
const goBack = useEvent((replace?: boolean) => {
const backHref = window.location.href.split("/").slice(0, -1).join("/");
if (replace) {
window.location.replace(backHref);
} else {
window.history.back();
}
return;
});
const deleteAllowed = options?.allowDelete?.(config) ?? true;
const editAllowed = options?.allowEdit?.(config) ?? true;
const showAlert = options?.showAlert?.(config) ?? undefined;
console.log("--setting", { schema, config, prefix, path, exclude });
const [reducedSchema, reducedConfig, extracted] = extractSchema(schema, config, exclude);
console.log({ reducedSchema, reducedConfig, extracted });
const extractedKeys = Object.keys(extracted);
const selectedSubKey =
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
const onToggleEdit = useEvent(() => {
if (!editAllowed) return;
setEditing((prev) => !prev);
//formRef.current?.cancel();
});
const onSave = useEvent(async () => {
if (!editAllowed || !editing) return;
if (formRef.current?.validateForm()) {
setSubmitting(true);
const data = formRef.current?.formData();
const [module, ...restOfPath] = path;
let success: boolean;
console.log("save:data", { module, restOfPath }, data);
if (restOfPath.length > 0) {
// patch
console.log("-> patch", { module, path: restOfPath.join(".") }, data);
success = await actions.patch(module as any, restOfPath.join("."), data);
} else {
// set
console.log("-> set", { module }, data);
success = await actions.set(module as any, data, true);
}
console.log("save:success", success);
if (success) {
//window.location.reload();
} else {
setSubmitting(false);
}
}
});
const handleDelete = useEvent(async () => {
if (!deleteAllowed) return;
const [module, ...restOfPath] = path;
if (window.confirm(`Are you sure you want to delete ${path.join(".")}`)) {
if (await actions.remove(module as any, restOfPath.join("."))) {
goBack(true);
}
}
});
if (!config) {
return (
<Empty
title="Not found"
description={`Configuration at path ${path.join(".")} doesn't exist.`}
buttonText="Go back"
/>
);
}
return (
<>
<AppShell.SectionHeader
className={path.length > 1 ? "pl-3" : ""}
scrollable
right={
<>
<Dropdown
items={[
{
label: "Inspect local schema",
onClick: () => {
schemaLocalModalRef.current?.open();
}
},
{
label: "Inspect schema",
onClick: () => {
schemaModalRef.current?.open();
}
},
deleteAllowed && {
label: "Delete",
destructive: true,
onClick: handleDelete
}
]}
position="bottom-end"
>
<IconButton Icon={TbSettings} />
</Dropdown>
<Button onClick={onToggleEdit} disabled={!editAllowed}>
{editing ? "Cancel" : "Edit"}
</Button>
{editing && (
<Button
variant="primary"
onClick={onSave}
disabled={submitting || !editAllowed}
>
{submitting ? "Save..." : "Save"}
</Button>
)}
</>
}
>
<Breadcrumbs path={path} />
</AppShell.SectionHeader>
<AppShell.Scrollable key={path.join("-")}>
{showAlert && (
<div className="flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4">
{showAlert}
</div>
)}
<div className="flex flex-col flex-grow p-3 gap-3">
<JsonSchemaForm
ref={formRef}
readonly={!editing}
schema={omit(reducedSchema, ["title"])}
formData={reducedConfig}
uiSchema={uiSchema}
onChange={console.log}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
{extractedKeys.length > 0 && (
<div className="flex flex-col max-w-full">
<AppShell.SectionHeaderTabs
items={extractedKeys.map((sub) => ({
as: Link,
label: ucFirst(sub),
href: `${prefix}/${sub}`.replace(/\/+/g, "/"),
active: selectedSubKey === sub,
badge: Object.keys(extracted[sub]?.config ?? {}).length
}))}
/>
<div className="flex flex-grow flex-col gap-3 p-3">
<Route
path="/:prop?"
component={({ params }) => {
const [, navigate] = useLocation();
const key = (params.prop ?? selectedSubKey) as string;
const localConfig = extracted[key]?.config ?? {};
const values = properties?.[key]?.tableValues
? properties?.[key]?.tableValues(localConfig)
: Object.entries(localConfig).map(([key, value]) => {
if (!value || typeof value !== "object") {
return {
key,
value
};
}
const fistValueKey = Object.keys(value)[0]!;
const firstValueKeyValue = value[fistValueKey];
const _value = omit(value as any, [fistValueKey]);
return {
key,
[fistValueKey]: firstValueKeyValue,
value: _value
};
});
const newSetting = properties?.[key]?.new;
if (!key) {
return (
<div className="h-80 flex justify-center align-center">
<Empty
title="No sub-setting selected"
description="Select one from the tab bar"
/>
</div>
);
}
return (
<>
{newSetting && (
<SettingNewModal
schema={newSetting.schema}
uiSchema={newSetting.uiSchema}
anyOfValues={newSetting.anyOfValues}
prefixPath={`/${key}/`}
path={[...path, key]}
generateKey={newSetting.generateKey}
/>
)}
<DataTable
data={values}
onClickRow={(row) => {
const firstKeyValue = Object.values(row)[0];
navigate(`/${key}/${firstKeyValue}`);
}}
/>
</>
);
}}
/>
</div>
</div>
)}
</AppShell.Scrollable>
<SettingSchemaModal
ref={schemaLocalModalRef}
title={path.join(".")}
tabs={[
{
title: "Schema",
json: reducedSchema
},
{
title: "Config",
json: reducedConfig
}
]}
/>
<SettingSchemaModal
ref={schemaModalRef}
title={path.join(".")}
tabs={[
{
title: "Schema",
json: schema
},
{
title: "Config",
json: config
}
]}
/>
</>
);
}

View File

@@ -0,0 +1,170 @@
import { useDisclosure, useFocusTrap } from "@mantine/hooks";
import type { TObject } from "core/utils";
import { omit } from "lodash-es";
import { useRef, useState } from "react";
import { TbCirclePlus, TbVariable } from "react-icons/tb";
import { useLocation } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import * as Formy from "../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown";
import { Modal } from "../../../components/overlay/Modal";
export type SettingsNewModalProps = {
schema: TObject;
uiSchema?: object;
anyOfValues?: Record<string, { label: string; icon?: any }>;
path: string[];
prefixPath: string;
generateKey?: string | ((formData: any) => string);
};
export const SettingNewModal = ({
schema,
uiSchema = {},
anyOfValues,
path,
prefixPath,
generateKey
}: SettingsNewModalProps) => {
const [location, navigate] = useLocation();
const [formSchema, setFormSchema] = useState(schema);
const [submitting, setSubmitting] = useState(false);
const { actions } = useBknd();
const [opened, { open, close }] = useDisclosure(false);
const isGeneratedKey = generateKey !== undefined;
const isStaticGeneratedKey = typeof generateKey === "string";
const [newKey, setNewKey] = useState(isStaticGeneratedKey ? generateKey : "");
const focusTrap = useFocusTrap(!isGeneratedKey);
const formRef = useRef<JsonSchemaFormRef>(null);
const isAnyOf = "anyOf" in schema;
function handleFormChange(data) {
if (generateKey && typeof generateKey === "function") {
handleKeyNameChange({
target: {
value: generateKey(data)
}
});
}
console.log("form change", data);
}
function handleKeyNameChange(e) {
const v = String(e.target.value);
if (v.length > 0 && !/^[a-zA-Z_][a-zA-Z0-9_ ]*$/.test(v)) {
console.log("no match", v);
return;
}
setNewKey(v.toLowerCase().replace(/ /g, "_").replace(/__+/g, "_"));
}
async function handleSave() {
if (formRef.current?.validateForm()) {
setSubmitting(true);
const data = formRef.current?.formData();
const [module, ...restOfPath] = path;
const addPath = [...restOfPath, newKey].join(".");
if (await actions.add(module as any, addPath, data)) {
navigate(prefixPath + newKey, {
replace: true
});
} else {
setSubmitting(false);
}
}
console.log("valid?", formRef.current?.validateForm());
console.log("data", newKey, formRef.current?.formData());
}
const anyOfItems = isAnyOf
? (schema.anyOf as any[])!.map((item) => {
const key = item.title;
const label = anyOfValues?.[key]?.label || key;
const icon = anyOfValues?.[key]?.icon;
return {
label,
icon,
onClick: () => {
setFormSchema(item);
open();
}
};
})
: [];
return (
<>
<div className="flex flex-row">
{isAnyOf ? (
<Dropdown position="top-start" items={anyOfItems} itemsClassName="gap-3">
<Button>Add new</Button>
</Dropdown>
) : (
<Button onClick={open}>Add new</Button>
)}
</div>
<Modal
open={opened}
allowBackdropClose={false}
onClose={close}
className="min-w-96 w-6/12 mt-[30px] mb-[20px]"
stickToTop
>
<div
className="border border-muted rounded-lg flex flex-col overflow-y-scroll font-normal"
style={{ maxHeight: "calc(100dvh - 100px)" }}
>
<div className="bg-muted py-2 px-4 text-primary/70 font-mono flex flex-row gap-2 items-center">
{[...path, newKey].join(".")}
</div>
<div className="flex flex-row gap-10 sticky top-0 bg-background z-10 p-4 border-b border-muted">
<div className="flex flex-row flex-grow items-center relative">
<Formy.Input
ref={focusTrap}
className="w-full"
placeholder="New unique key..."
onChange={handleKeyNameChange}
value={newKey}
disabled={isGeneratedKey}
/>
{isGeneratedKey && (
<div className="absolute h-full flex items-center z-10 right-3.5 top-0 bottom-0 font-mono font-sm opacity-50">
generated
</div>
)}
</div>
<div className="flex flex-row gap-2">
<Button onClick={close} disabled={submitting}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={submitting || newKey.length === 0}
>
Create
</Button>
</div>
</div>
<div className="flex flex-col p-4">
<JsonSchemaForm
ref={formRef}
/* readonly={!editing} */
schema={omit(formSchema, ["title"])}
uiSchema={uiSchema}
onChange={handleFormChange}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,73 @@
import { useDisclosure } from "@mantine/hooks";
import { forwardRef, useImperativeHandle } from "react";
import { useState } from "react";
import { TbVariable, TbX } from "react-icons/tb";
import { IconButton } from "../../../components/buttons/IconButton";
import { JsonViewer } from "../../../components/code/JsonViewer";
import { Modal } from "../../../components/overlay/Modal";
export type SettingsSchemaModalRef = {
open: () => void;
close: () => void;
isOpen: boolean;
};
type SettingSchemaModalProps = {
tabs: { title: string; json: object }[];
title?: string;
};
export const SettingSchemaModal = forwardRef<SettingsSchemaModalRef, SettingSchemaModalProps>(
({ tabs, title }, ref) => {
const [opened, { open, close }] = useDisclosure(false);
const [index, setIndex] = useState(0);
const tab = tabs[index];
useImperativeHandle(ref, () => ({
open,
close,
isOpen: opened
}));
if (!tab) return null;
return (
<Modal
open={opened}
onClose={close}
className="min-w-96 w-9/12 mt-[80px] mb-[20px]"
stickToTop
>
<div
className="border border-primary rounded-lg flex flex-col overflow-scroll font-normal"
style={{ maxHeight: "calc(100dvh - 100px)" }}
>
{title && (
<div className="bg-primary py-2 px-4 text-background font-mono flex flex-row gap-2 items-center">
<TbVariable size={20} className="opacity-50" />
{title}
</div>
)}
<div className="flex flex-row justify-between sticky top-0 bg-background z-10 py-4 px-5">
<div className="flex flex-row gap-3">
{tabs.map((t, key) => (
<button
key={key}
onClick={() => setIndex(key)}
data-active={key === index ? 1 : undefined}
className="font-mono data-[active]:font-bold"
>
{t.title}
</button>
))}
</div>
<IconButton Icon={TbX} onClick={close} />
</div>
<div className="">
<JsonViewer json={tab.json} expand={6} showSize showCopy />
</div>
</div>
</Modal>
);
}
);

View File

@@ -0,0 +1,186 @@
import { modals } from "@mantine/modals";
import { IconSettings } from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { Route, Switch } from "wouter";
import { useBknd } from "../../client";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { bkndModals } from "../../modals";
import { Setting } from "./components/Setting";
import { AuthSettings } from "./routes/auth.settings";
import { DataSettings } from "./routes/data.settings";
import { FlowsSettings } from "./routes/flows.settings";
function SettingsSidebar() {
const { version, schema } = useBknd();
useBrowserTitle(["Settings"]);
const modules = Object.keys(schema).map((key) => {
return {
title: schema[key].title ?? ucFirst(key),
key
};
});
return (
<AppShell.Sidebar>
<AppShell.SectionHeader right={<span className="font-mono">v{version}</span>}>
Settings
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<nav className="flex flex-col flex-1 gap-1">
{modules.map((module, key) => (
<AppShell.SidebarLink as={Link} key={key} href={`/${module.key}`}>
{module.title}
</AppShell.SidebarLink>
))}
</nav>
</div>
{/*<button
onClick={() =>
modals.openContextModal({
modal: "test",
title: "Test Modal",
innerProps: { modalBody: "This is a test modal" }
})
}
>
modal
</button>
<button
onClick={() =>
bkndModals.open(bkndModals.ids.test, { modalBody: "test" }, { title: "what" })
}
>
modal2
</button>
<button onClick={() => bkndModals.open("test", { modalBody: "test" })}>modal</button>
<button
onClick={() =>
bkndModals.open("debug", {
data: {
one: { what: 1 }
}
})
}
>
debug
</button>*/}
</AppShell.Scrollable>
</AppShell.Sidebar>
);
}
export default function SettingsRoutes() {
useBknd({ withSecrets: true });
return (
<>
<SettingsSidebar />
<AppShell.Main>
<Switch>
<Route
path="/"
component={() => (
<Empty
Icon={IconSettings}
title="No Setting selected"
description="Please select a setting from the left sidebar."
/>
)}
/>
<SettingRoutesRoutes />
<Route
path="*"
component={() => (
<Empty
Icon={IconSettings}
title="Settings not found"
description="Check other options."
/>
)}
/>
</Switch>
</AppShell.Main>
</>
);
}
const uiSchema = {
server: {
cors: {
allow_methods: {
"ui:widget": "checkboxes"
},
allow_headers: {
"ui:options": {
orderable: false
}
}
}
},
media: {
adapter: {
"ui:options": {
label: false
}
/*type: {
"ui:widget": "hidden"
}*/
}
}
};
const SettingRoutesRoutes = () => {
const { schema, config } = useBknd();
console.log("flows", {
schema: schema.flows,
config: config.flows
});
return (
<>
<FallbackRoutes
module="server"
schema={schema}
config={config}
uiSchema={uiSchema.server}
/>
<DataSettings schema={schema.data} config={config.data} />
<AuthSettings schema={schema.auth} config={config.auth} />
<FallbackRoutes module="media" schema={schema} config={config} uiSchema={uiSchema.media} />
<FlowsSettings schema={schema.flows} config={config.flows} />
</>
);
};
const FallbackRoutes = ({ module, schema, config, ...settingProps }) => {
const { app } = useBknd();
const basepath = app.getAdminConfig();
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
return (
<Route path={`/${module}`} nest>
<Switch>
<Route
path="/"
component={() => (
<Setting
{...settingProps}
schema={schema[module]}
config={config[module]}
prefix={`${prefix}/${module}`}
path={[module]}
/>
)}
nest
/>
</Switch>
</Route>
);
};

View File

@@ -0,0 +1,156 @@
import { transformObject } from "core/utils";
import { cloneDeep, pick } from "lodash-es";
import { Route, Switch } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Setting } from "../components/Setting";
const uiSchema = {
jwt: {
basepath: {
"ui:options": {
label: false
}
},
fields: {
"ui:options": {
orderable: false
}
}
},
strategies: {
additionalProperties: {
"ui:widget": "select",
type: {
"ui:widget": "hidden"
}
},
type: {
"ui:widget": "hidden"
}
},
roles: {
"ui:options": {
orderable: false
},
permissions: {
items: {
"ui:widget": "checkboxes"
},
"ui:widget": "checkboxes"
}
}
};
export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
const _s = useBknd();
const _schema = cloneDeep(_unsafe_copy);
const { basepath } = _s.app.getAdminConfig();
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
try {
const user_entity = config.entity_name ?? "users";
const entities = _s.config.data.entities ?? {};
console.log("entities", entities, user_entity);
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean);
if (user_fields.length > 0) {
console.log("user_fields", user_fields);
_schema.properties.jwt.properties.fields.items.enum = user_fields;
_schema.properties.jwt.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
}
} catch (e) {}
console.log("_s", _s);
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions;
roleSchema.properties.permissions.uniqueItems = true;
}
return (
<Route path="/auth" nest>
<Switch>
<Route
path="/roles/:role"
component={({ params: { role } }) => {
return (
<Setting
schema={roleSchema}
uiSchema={uiSchema.roles}
config={config.roles?.[role] as any}
path={["auth", "roles", role]}
prefix={`${prefix}/auth/roles/${role}`}
/>
);
}}
nest
/>
<Route
path="/strategies/:strategy"
component={({ params: { strategy } }) => {
const c = config.strategies?.[strategy];
// @ts-ignore
const s = _schema.properties.strategies.additionalProperties as any;
const editSchema = s.anyOf.find((x) => x.properties.type.const === c?.type);
return (
<Setting
schema={editSchema ?? s}
config={config.strategies?.[strategy] as any}
path={["auth", "strategies", strategy]}
prefix={`${prefix}/auth/strategies/${strategy}`}
/>
);
}}
nest
/>
<Route
path="/"
component={() => (
<Setting
schema={_schema}
uiSchema={uiSchema}
config={config}
properties={{
strategies: {
extract: true,
tableValues: (strategies: any) => {
return Object.values(
transformObject(strategies, (s, name) => ({
key: name,
type: s.type
}))
);
},
new: {
schema: _schema.properties.strategies.additionalProperties,
uiSchema: uiSchema.strategies,
generateKey: (data) => {
return data.type === "password"
? "password"
: data.config.name?.toLowerCase() || "";
}
}
},
roles: {
extract: true,
new: {
schema: roleSchema,
uiSchema: uiSchema.roles
}
}
}}
prefix={`${prefix}/auth`}
path={["auth"]}
/>
)}
nest
/>
</Switch>
</Route>
);
};

View File

@@ -0,0 +1,347 @@
import { cloneDeep, transform } from "lodash-es";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { Route, Switch } from "wouter";
import type { ModuleConfigs, ModuleSchemas } from "../../../../modules";
import { useBknd } from "../../../client";
import { Setting } from "../components/Setting";
export const dataFieldsUiSchema = {
config: {
fillable: {
"ui:options": {
wrap: true
},
anyOf: [
{},
{
"ui:widget": "checkboxes"
}
]
},
hidden: {
"ui:options": {
wrap: true
},
anyOf: [
{},
{
"ui:widget": "checkboxes"
}
]
},
schema: {
"ui:field": "JsonField"
},
ui_schema: {
"ui:field": "JsonField"
}
}
};
const fieldsAnyOfValues = fieldSpecs
.filter((s) => s.type !== "primary")
.reduce((acc, s) => {
acc[s.type] = {
label: s.label,
icon: s.icon
};
return acc;
}, {});
const relationAnyOfValues = {
"1:1": {
label: "One-to-One"
},
"n:1": {
label: "Many-to-One"
},
"m:n": {
label: "Many-to-Many"
},
poly: {
label: "Polymorphic"
}
};
export const DataSettings = ({
schema,
config
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
const { app } = useBknd();
const basepath = app.getAdminConfig().basepath;
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const entities = Object.keys(config.entities ?? {});
function fillEntities(schema: any, key: string = "entity") {
if (entities.length === 0) return;
if (schema.properties && key in schema.properties) {
schema.properties[key].enum = entities;
}
if ("anyOf" in schema) {
schema.anyOf.forEach((s) => fillEntities(s, key));
}
}
return (
<Route path="/data" nest>
<Switch>
<Route
path="/entities/:entity/fields/:field"
component={({ params: { entity, field } }) => {
const c = config.entities?.[entity]?.fields?.[field];
// @ts-ignore
const s = schema.properties.entities.additionalProperties?.properties?.fields
.additionalProperties as any;
const editSchema = s.anyOf.find((x) => x.properties.type.const === c?.type);
//console.log("editSchema", editSchema);
return (
<Setting
schema={editSchema ?? s}
// @ts-ignore
config={c}
prefix={`${prefix}/data/entities/${entity}/fields/${field}`}
path={["data", "entities", entity, "fields", field]}
options={{
showAlert: (config: any) => {
// it's weird, but after creation, the config is not set (?)
if (config?.type === "primary") {
return "Modifying the primary field may result in strange behaviors.";
}
return;
}
}}
uiSchema={dataFieldsUiSchema}
/>
);
}}
nest
/>
<Route
path="/entities/:entity"
component={({ params: { entity } }) => {
const s = schema.properties.entities.additionalProperties as any;
const editSchema = cloneDeep(s);
editSchema.properties.type.readOnly = true;
const fieldsSchema = {
anyOf: editSchema.properties.fields.additionalProperties.anyOf.filter(
(s) => s.properties.type.const !== "primary"
)
} as any;
return (
<Setting
schema={editSchema}
config={config.entities?.[entity] as any}
options={{
showAlert: (config: any) => {
if (config.type === "system") {
return "Modifying the system entities may result in strange behaviors.";
}
return;
}
}}
properties={{
fields: {
extract: true,
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
property: key,
type: value.type,
required: value.config?.required ? "Yes" : "No"
});
},
[] as any[]
),
new: {
schema: fieldsSchema,
uiSchema: dataFieldsUiSchema,
anyOfValues: fieldsAnyOfValues
}
}
}}
path={["data", "entities", entity]}
prefix={`${prefix}/data/entities/${entity}`}
/>
);
}}
nest
/>
{/* indices */}
<Route
path="/indices/:index"
component={({ params: { index } }) => {
const indicesSchema = schema.properties.indices.additionalProperties as any;
if (entities.length > 0) {
fillEntities(indicesSchema, "entity");
//indicesSchema.properties.entity.enum = entities;
}
return (
<Setting
schema={schema.properties.indices.additionalProperties as any}
config={config.indices?.[index] as any}
path={["data", "indices", index]}
prefix={`${prefix}/data/indices/${index}`}
/>
);
}}
nest
/>
{/* relations */}
<Route
path="/relations/:relation"
component={({ params: { relation } }) => {
const c = config.relations?.[relation];
// @ts-ignore
const s = schema.properties.relations.additionalProperties as any;
const editSchema = s.anyOf.find((x) => x.properties.type.const === c?.type);
if (entities.length > 0) {
fillEntities(editSchema, "source");
fillEntities(editSchema, "target");
}
return (
<Setting
schema={editSchema}
config={c}
path={["data", "relations", relation]}
prefix={`${prefix}/data/relations/${relation}`}
/>
);
}}
nest
/>
<Route
path="/"
component={() => {
const newIndex = schema.properties.indices.additionalProperties as any;
const newRelation = schema.properties.relations.additionalProperties as any;
fillEntities(newIndex, "entity");
fillEntities(newRelation, "source");
fillEntities(newRelation, "target");
return (
<Setting
schema={schema}
config={config}
properties={{
entities: {
extract: true,
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
name: key,
type: value.type,
fields: Object.keys(value.fields ?? {}).length
});
},
[] as any[]
),
new: {
schema: schema.properties.entities.additionalProperties as any,
uiSchema: {
fields: {
"ui:widget": "hidden"
},
type: {
"ui:widget": "hidden"
}
}
}
},
relations: {
extract: true,
new: {
schema: schema.properties.relations.additionalProperties as any,
generateKey: (data: any) => {
const parts = [
data.type.replace(":", ""),
data.source,
data.target,
data.config?.mappedBy,
data.config?.inversedBy,
data.config?.connectionTable,
data.config?.connectionTableMappedName
].filter(Boolean);
return [...new Set(parts)].join("_");
},
anyOfValues: relationAnyOfValues
},
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
name: key,
type: value.type,
source: value.source,
target: value.target
});
},
[] as any[]
)
},
indices: {
extract: true,
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
name: key,
entity: value.entity,
fields: value.fields.join(", "),
unique: value.unique ? "Yes" : "No"
});
},
[] as any[]
),
new: {
schema: newIndex,
uiSchema: {
fields: {
"ui:options": {
orderable: false
}
}
},
generateKey: (data: any) => {
const parts = [
"idx",
data.entity,
data.unique && "unique",
...data.fields.filter(Boolean)
].filter(Boolean);
return parts.join("_");
}
}
}
}}
prefix={`${prefix}/data`}
path={["data"]}
/>
);
}}
nest
/>
</Switch>
</Route>
);
};

View File

@@ -0,0 +1,212 @@
import { transform } from "lodash-es";
import { Route, Switch } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Setting } from "../components/Setting";
const uiSchema = {
jwt: {
basepath: {
"ui:options": {
label: false
}
},
fields: {
"ui:options": {
orderable: false
}
}
},
strategies: {
additionalProperties: {
"ui:widget": "select",
type: {
"ui:widget": "hidden"
}
},
type: {
"ui:widget": "hidden"
}
}
};
export const FlowsSettings = ({ schema, config }) => {
const { app } = useBknd();
const { basepath } = app.getAdminConfig();
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
function fillTasks(schema: any, flow: any, key: string) {
const tasks = Object.keys(flow.tasks ?? {});
if (tasks.length === 0) return;
if (schema.properties && key in schema.properties) {
schema.properties[key].enum = tasks;
}
if ("anyOf" in schema) {
schema.anyOf.forEach((s) => fillTasks(s, flow, key));
}
}
return (
<Route path="/flows" nest>
<Switch>
<Route
path="/flows/:flow/connections/:connection"
component={({ params: { flow, connection } }) => {
const flowConfig = config.flows?.[flow];
const c = config.flows?.[flow]?.connections?.[connection];
const s =
schema.properties.flows.additionalProperties.properties.connections
.additionalProperties;
fillTasks(s, flowConfig, "source");
fillTasks(s, flowConfig, "target");
return (
<Setting
schema={s}
config={c}
path={["flows", "flows", flow, "connections", connection]}
prefix={`${prefix}/flows/flows/${flow}/connections/${connection}`}
/>
);
}}
nest
/>
<Route
path="/flows/:flow/tasks/:task"
component={({ params: { flow, task } }) => {
const c = config.flows?.[flow]?.tasks?.[task];
const s =
schema.properties.flows.additionalProperties.properties.tasks
.additionalProperties;
const editSchema = s.anyOf.find((x) => x.properties.type.const === c?.type);
return (
<Setting
schema={editSchema}
config={c}
path={["flows", "flows", flow, "tasks", task]}
prefix={`${prefix}/flows/flows/${flow}/tasks/${task}`}
uiSchema={{
params: {
render: {
"ui:field": "LiquidJsField"
}
}
}}
/>
);
}}
nest
/>
<Route
path="/flows/:flow"
component={({ params: { flow } }) => {
const c = config.flows?.[flow];
const flowSchema = schema.properties.flows.additionalProperties;
const newTask = flowSchema.properties.tasks.additionalProperties;
const newConnection = flowSchema.properties.connections.additionalProperties;
fillTasks(flowSchema, c, "start_task");
fillTasks(flowSchema, c, "responding_task");
fillTasks(newConnection, c, "source");
fillTasks(newConnection, c, "target");
return (
<Setting
schema={flowSchema}
config={c}
path={["flows", "flows", flow]}
prefix={`${prefix}/flows/flows/${flow}`}
properties={{
tasks: {
extract: true,
new: {
schema: newTask
},
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
name: key,
type: value.type
});
},
[] as any[]
)
},
connections: {
extract: true,
new: {
schema: newConnection,
generateKey: crypto.randomUUID() as string
},
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
id: key,
source: value.source,
target: value.target,
condition: value.config.condition?.type
});
},
[] as any[]
)
}
}}
/>
);
}}
nest
/>
<Route
path="/"
component={() => (
<Setting
schema={schema}
uiSchema={uiSchema}
config={config}
properties={{
flows: {
extract: true,
new: {
schema: schema.properties.flows.additionalProperties as any
/*uiSchema: {
fields: {
"ui:widget": "hidden"
}
}*/
},
tableValues: (config: any) =>
transform(
config,
(acc, value, key) => {
acc.push({
name: key,
trigger: value.trigger.type,
mode: value.trigger.config.mode,
start_task: value.start_task,
responding_task: value.responding_task
});
},
[] as any[]
)
}
}}
prefix={`${prefix}/flows`}
path={["flows"]}
/>
)}
nest
/>
</Switch>
</Route>
);
};

View File

@@ -0,0 +1,50 @@
import type { Static, TObject } from "core/utils";
import type { JSONSchema7 } from "json-schema";
import { cloneDeep, omit, pick } from "lodash-es";
export function extractSchema<
Schema extends TObject,
Keys extends keyof Schema["properties"],
Config extends Static<Schema>
>(
schema: Schema,
config: Config,
keys: Keys[]
): [
JSONSchema7,
Partial<Config>,
{
[K in Keys]: {
// @ts-ignore
config: Config[K];
schema: Schema["properties"][K];
};
}
] {
if (!schema.properties) {
return [{ ...schema }, config, {} as any];
}
const newSchema = cloneDeep(schema);
const updated = {
...newSchema,
properties: omit(newSchema.properties, keys)
};
if (updated.required) {
updated.required = updated.required.filter((key) => !keys.includes(key as any));
}
const extracted = {} as any;
for (const key of keys) {
extracted[key] = {
// @ts-ignore
config: config[key],
// @ts-ignore
schema: newSchema.properties[key]
};
}
const reducedConfig = omit(config, keys) as any;
return [updated, reducedConfig, extracted];
}

View File

@@ -0,0 +1,81 @@
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
import SwaggerTest from "ui/routes/test/tests/swagger-test";
import { Route, useParams } from "wouter";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { AppShell } from "../../layouts/AppShell";
import FlowCreateSchemaTest from "../../routes/test/tests/flow-create-schema-test";
import FlowFormTest from "../../routes/test/tests/flow-form-test";
import ModalTest from "../../routes/test/tests/modal-test";
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
import DropdownTest from "./tests/dropdown-test";
import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test";
import JsonFormTest from "./tests/jsonform-test";
import { LiquidJsTest } from "./tests/liquid-js-test";
import MantineTest from "./tests/mantine-test";
import ReactHookErrors from "./tests/react-hook-errors";
import ReactFlowTest from "./tests/reactflow-test";
import SchemaTest from "./tests/schema-test";
import SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test";
const tests = {
DropdownTest,
ModalTest,
JsonFormTest,
FlowFormTest,
QueryJsonFormTest,
FlowCreateSchemaTest,
ReactFlowTest,
SchemaTest,
MantineTest,
LiquidJsTest,
SqlAiTest,
SortableTest,
ReactHookErrors,
EntityFieldsForm,
FlowsTest,
AppShellAccordionsTest,
SwaggerTest
} as const;
export default function TestRoutes() {
return (
<TestRoot>
<Route path="/" component={() => <Empty title="Test" description="Select one." />} />
<Route path="/:test" component={RenderTest} />
</TestRoot>
);
}
function RenderTest() {
const params = useParams();
if (!params.test || !(params.test in tests)) {
return <Empty title="Test not found" />;
}
const Test = tests[params.test];
return <Test />;
}
function TestRoot({ children }) {
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader>Tests</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<nav className="flex flex-col flex-1 gap-1">
{Object.entries(tests).map(([key, Component]) => (
<AppShell.SidebarLink key={key} as={Link} href={`/${key}`}>
{key}
</AppShell.SidebarLink>
))}
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main>
</>
);
}

View File

@@ -0,0 +1,124 @@
import {
IconAlignJustified,
IconBolt,
IconChevronDown,
IconChevronUp,
IconCirclesRelation,
IconSettings,
IconUser
} from "@tabler/icons-react";
import { useState } from "react";
import { TbDots } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes";
const Item = ({
title,
open,
toggle,
ActiveIcon = IconChevronUp,
children,
renderHeaderRight
}: {
title: string;
open: boolean;
toggle: () => void;
ActiveIcon?: any;
children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
}) => (
<div
style={{ minHeight: 49 }}
className={twMerge(
"flex flex-col flex-animate overflow-hidden",
open
? "flex-open border-b border-b-muted"
: "flex-initial cursor-pointer hover:bg-primary/5"
)}
>
<div
className={twMerge(
"flex flex-row border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2"
)}
onClick={toggle}
>
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
<div className="flex flex-grow" />
{renderHeaderRight?.({ open })}
</div>
<div
className={twMerge(
"overflow-y-scroll transition-all",
open ? " flex-grow" : "h-0 opacity-0"
)}
>
<div className="flex flex-col gap-5 p-4 ">{children}</div>
</div>
</div>
);
export default function AppShellAccordionsTest() {
const [value, setValue] = useState("1");
function toggle(value) {
return () => setValue(value);
}
return (
<div className="flex flex-col h-full">
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Settings"
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: "Quizzes" }]}
backTo="/"
/>
</AppShell.SectionHeader>
<Item
title="Fields"
open={value === "1"}
toggle={toggle("1")}
ActiveIcon={IconAlignJustified}
renderHeaderRight={({ open }) =>
open ? (
<Button variant="primary" disabled={!open}>
Update
</Button>
) : null
}
/>
<Item
title="Settings"
open={value === "2"}
toggle={toggle("2")}
ActiveIcon={IconSettings}
/>
<Item
title="Relations"
open={value === "3"}
toggle={toggle("3")}
ActiveIcon={IconCirclesRelation}
/>
<Item title="Indices" open={value === "4"} toggle={toggle("4")} ActiveIcon={IconBolt} />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Dropdown } from "../../../components/overlay/Dropdown";
export default function DropdownTest() {
return (
<div className="flex flex-col w-full h-full justify-center items-center">
<Dropdown
items={[
{ label: "Item 1", value: "item1" },
{ label: "Item 2", value: "item2" },
{ label: "Item 3", value: "item3" },
]}
>
<button>Dropdown</button>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,296 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Select, Switch, Tabs, TextInput, Textarea, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Type } from "@sinclair/typebox";
import { StringEnum, StringIdentifier, transformObject } from "core/utils";
import { FieldClassMap } from "data";
import { omit } from "lodash-es";
import {
type FieldArrayWithId,
type FieldValues,
type UseControllerProps,
type UseFormReturn,
useController,
useFieldArray,
useForm
} from "react-hook-form";
import { TbChevronDown, TbChevronUp, TbGripVertical, TbTrash } from "react-icons/tb";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { MantineSelect } from "../../../components/form/hook-form-mantine/MantineSelect";
const fieldSchemas = transformObject(omit(FieldClassMap, ["primary"]), (value) => value.schema);
const fieldSchema = Type.Union(
Object.entries(fieldSchemas).map(([type, schema]) =>
Type.Object(
{
type: Type.Const(type),
name: StringIdentifier,
config: Type.Optional(schema)
},
{
additionalProperties: false
}
)
)
);
const schema = Type.Object({
fields: Type.Array(fieldSchema)
});
const fieldSchema2 = Type.Object({
type: StringEnum(Object.keys(fieldSchemas)),
name: StringIdentifier
});
function specificFieldSchema(type: keyof typeof fieldSchemas) {
return Type.Omit(fieldSchemas[type], [
"label",
"description",
"required",
"fillable",
"hidden",
"virtual"
]);
}
export default function EntityFieldsForm() {
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue
} = useForm({
mode: "onTouched",
resolver: typeboxResolver(schema),
defaultValues: {
fields: [{ type: "text", name: "", config: {} }],
sort: { by: "-1", dir: "asc" }
}
});
const defaultType = Object.keys(fieldSchemas)[0];
const { fields, append, prepend, remove, swap, move, insert, update } = useFieldArray({
control, // control props comes from useForm (optional: if you are using FormProvider)
name: "fields" // unique name for your Field Array
});
function handleAppend() {
append({ type: "text", name: "", config: {} });
}
return (
<div className="flex flex-col gap-1 p-8">
{/*{fields.map((field, index) => (
<EntityField
key={field.id}
field={field}
index={index}
form={{ watch, register, setValue, getValues, control }}
defaultType={defaultType}
remove={remove}
/>
))}*/}
{fields.map((field, index) => (
<EntityFieldForm key={field.id} value={field} index={index} update={update} />
))}
<Button className="justify-center" onClick={handleAppend}>
Add Field
</Button>
<div>
<pre>{JSON.stringify(watch(), null, 2)}</pre>
</div>
</div>
);
}
function EntityFieldForm({ update, index, value }) {
const {
register,
handleSubmit,
control,
formState: { errors }
} = useForm({
mode: "onBlur",
resolver: typeboxResolver(
Type.Object({
type: StringEnum(Object.keys(fieldSchemas)),
name: Type.String({ minLength: 1, maxLength: 3 })
})
),
defaultValues: value
});
function handleUpdate({ id, ...data }) {
console.log("data", data);
update(index, data);
}
return (
<form>
<MantineSelect
control={control}
name="type"
data={[...Object.keys(fieldSchemas), "test"]}
/>
<TextInput
label="Name"
placeholder="name"
classNames={{ root: "w-full" }}
{...register("name")}
error={errors.name?.message as any}
/>
</form>
);
}
function EntityFieldController({
name,
control,
defaultValue,
rules,
shouldUnregister
}: UseControllerProps & {
index: number;
}) {
const {
field: { value, onChange: fieldOnChange, ...field },
fieldState
} = useController({
name,
control,
defaultValue,
rules,
shouldUnregister
});
return <div>field</div>;
}
function EntityField({
field,
index,
form: { watch, register, setValue, getValues, control },
remove,
defaultType
}: {
field: FieldArrayWithId;
index: number;
form: Pick<UseFormReturn<any>, "watch" | "register" | "setValue" | "getValues" | "control">;
remove: (index: number) => void;
defaultType: string;
}) {
const [opened, handlers] = useDisclosure(false);
const prefix = `fields.${index}` as const;
const name = watch(`${prefix}.name`);
const enabled = name?.length > 0;
const type = watch(`${prefix}.type`);
//const config = watch(`${prefix}.config`);
const selectFieldRegister = register(`${prefix}.type`);
//console.log("type", type, specificFieldSchema(type as any));
function handleDelete(index: number) {
return () => {
if (name.length === 0) {
remove(index);
return;
}
window.confirm(`Sure to delete "${name}"?`) && remove(index);
};
}
return (
<div key={field.id} className="flex flex-col border border-muted rounded">
<div className="flex flex-row gap-2 px-2 pt-1 pb-2">
<div className="flex items-center">
<IconButton Icon={TbGripVertical} className="mt-1" />
</div>
<div className="flex flex-row flex-grow gap-1">
<div>
<Select
label="Type"
data={[...Object.keys(fieldSchemas), "test"]}
defaultValue={defaultType}
onBlur={selectFieldRegister.onBlur}
onChange={(value) => {
setValue(`${prefix}.type`, value as any);
setValue(`${prefix}.config`, {} as any);
}}
/>
</div>
<TextInput
label="Name"
placeholder="name"
classNames={{ root: "w-full" }}
{...register(`fields.${index}.name`)}
/>
</div>
<div className="flex items-end ">
<div className="flex flex-row gap-1">
<Tooltip label="Specify a property name to see options." disabled={enabled}>
<Button
IconRight={opened ? TbChevronUp : TbChevronDown}
onClick={handlers.toggle}
variant={opened ? "default" : "ghost"}
disabled={!enabled}
>
Options
</Button>
</Tooltip>
<IconButton Icon={TbTrash} onClick={handleDelete(index)} />
</div>
</div>
</div>
{/*{enabled && opened && (
<div className="flex flex-col border-t border-t-muted px-3 py-2">
<Tabs defaultValue="general">
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="specific">Specific</Tabs.Tab>
<Tabs.Tab value="visibility">Visiblity</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<div className="flex flex-col gap-2 pt-3 pb-1" key={`${prefix}_${type}`}>
<Switch
label="Required"
{...register(`${prefix}.config.required` as any)}
/>
<TextInput
label="Label"
placeholder="Label"
{...register(`${prefix}.config.label` as any)}
/>
<Textarea
label="Description"
placeholder="Description"
{...register(`${prefix}.config.description` as any)}
/>
<Switch label="Virtual" {...register(`${prefix}.config.virtual` as any)} />
</div>
</Tabs.Panel>
<Tabs.Panel value="specific">
<div className="flex flex-col gap-2 pt-3 pb-1">
<JsonSchemaForm
key={type}
schema={specificFieldSchema(type as any)}
uiSchema={dataFieldsUiSchema.config}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={(value) => {
setValue(`${prefix}.config`, {
...getValues([`fields.${index}.config`])[0],
...value
});
}}
/>
</div>
</Tabs.Panel>
</Tabs>
</div>
)}*/}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { parse } from "core/utils";
import { AppFlows } from "flows/AppFlows";
import { useState } from "react";
import { JsonViewer } from "../../../components/code/JsonViewer";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
export default function FlowCreateSchemaTest() {
//const schema = flowsConfigSchema;
const schema = new AppFlows().getSchema();
const [data, setData] = useState(parse(schema, {}));
return (
<Scrollable>
<div className="flex flex-col p-3">
<JsonSchemaForm
schema={schema}
onChange={setData}
className="legacy hide-required-mark"
/>
</div>
<JsonViewer json={data} expand={9} />
</Scrollable>
);
}

View File

@@ -0,0 +1,18 @@
import { FetchTask } from "flows";
import { useState } from "react";
import { TaskForm } from "ui/modules/flows/components/form/TaskForm";
export default function FlowFormTest() {
const [data, setData] = useState(null);
const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "{{ input.mode }}"
});
return (
<div className="flex flex-col p-3">
<TaskForm task={task} onChange={setData as any} />
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { type ElementProps, Input, Select, TagsInput, TextInput } from "@mantine/core";
import { useToggle } from "@mantine/hooks";
import {
IconBolt,
IconBoltFilled,
IconDatabase,
IconGlobeFilled,
IconMinus,
IconPlayerPlay,
IconPlus,
IconTrash,
IconWorld
} from "@tabler/icons-react";
import { useState } from "react";
import { TbPlayerPlayFilled } from "react-icons/tb";
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
import { KeyValueInput } from "ui/modules/flows/components2/form/KeyValueInput";
import type { AppFlowsSchema } from "../../../../modules";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { JsonEditor } from "../../../components/code/JsonEditor";
import { FloatingSelect } from "../../../components/form/FloatingSelect/FloatingSelect";
import { SegmentedControl } from "../../../components/form/SegmentedControl";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
const TRIGGERS = {
http: {
type: "http",
config: {
mode: "sync",
method: "GET",
response_type: "json",
path: "/trigger_http"
}
}
};
const TASKS = {
fetch: {
type: "fetch",
params: {
method: "GET",
headers: [],
url: "https://jsonplaceholder.typicode.com/todos/1"
}
}
};
export default function FlowsTest() {
return (
<Scrollable>
<div className="flex flex-col justify-center p-4 w-full h-full items-center gap-10">
<TriggerComponent />
<TaskDbQueryMultipleComponent />
<TaskFetchComponent />
</div>
</Scrollable>
);
}
const NodeHeader = ({
Icon,
iconProps,
rightSection,
initialValue,
onChange
}: {
Icon: React.FC<any>;
iconProps?: ElementProps<"svg">;
rightSection?: React.ReactNode;
initialValue: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<DefaultNode.Header className="justify-between gap-3">
<div className="flex flex-row flex-grow gap-1 items-center">
<Icon {...{ width: 16, height: 16, ...(iconProps ?? {}) }} />
<input
type="text"
value={initialValue}
onChange={onChange || (() => {})}
className="font-mono font-semibold bg-transparent w-full rounded-lg outline-none pl-1.5 hover:bg-background/30 transition-colors focus:bg-background/60"
/>
</div>
<div>
{/*{rightSection}*/}
<IconButton Icon={TbPlayerPlayFilled} size="sm" />
</div>
</DefaultNode.Header>
);
const TriggerComponent = () => {
return (
<DefaultNode className="w-96">
<NodeHeader Icon={IconBoltFilled} initialValue="test_flow" />
<DefaultNode.Content className="gap-3">
<div className="flex flex-row justify-between items-center">
<SegmentedControl
label="Trigger Type"
defaultValue="manual"
data={[
{ label: "Manual", value: "manual" },
{ label: "HTTP", value: "http" },
{ label: "Event", value: "event", disabled: true }
]}
/>
<SegmentedControl
label="Execution Mode"
defaultValue="async"
data={[
{ label: "Async", value: "async" },
{ label: "Sync", value: "sync" }
]}
/>
</div>
<div className="flex flex-row gap-2 items-center">
<Select
className="w-36"
label="Method"
defaultValue="GET"
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
/>
<TextInput
className="w-full"
label="Mapping Path"
placeholder="/trigger_http"
classNames={{ wrapper: "font-mono pt-px" }}
/>
</div>
<div className="flex flex-row gap-2 items-center">
<SegmentedControl
className="w-full"
label="Response Type"
defaultValue="json"
data={[
{ label: "JSON", value: "json" },
{ label: "HTML", value: "html" },
{ label: "Text", value: "text" }
]}
/>
</div>
</DefaultNode.Content>
</DefaultNode>
);
};
const TaskFetchComponent = () => {
const [advanced, toggle] = useToggle([true, false]);
return (
<DefaultNode className="w-[400px]">
<NodeHeader Icon={IconWorld} initialValue="fetch_something" />
<DefaultNode.Content className="gap-3">
<div className="flex flex-row gap-2 items-center">
<Select
className="w-36"
label="Method"
defaultValue="GET"
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
/>
<TextInput
className="w-full"
label="Mapping Path"
placeholder="/trigger_http"
classNames={{ wrapper: "font-mono pt-px" }}
/>
</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" />
<KeyValueInput label="Headers" />
<div className="flex flex-row gap-2 items-center">
<Input.Wrapper className="w-full">
<Input.Label>Body</Input.Label>
<SegmentedControl data={["None", "Form", "JSON"]} defaultValue="JSON" />
<KeyValueInput label="" />
</Input.Wrapper>
</div>
</>
)}
</DefaultNode.Content>
</DefaultNode>
);
};
const TaskDbQueryMultipleComponent = () => {
return (
<DefaultNode className="w-[400px]">
<NodeHeader Icon={IconDatabase} initialValue="query_multiple" />
<DefaultNode.Content className="gap-3">
<div className="flex flex-row gap-2 items-center">
<Select
className="w-6/12"
label="Entity"
placeholder="Select entity"
data={["users", "posts", "comments"]}
/>
<Select
className="w-4/12"
label="Sort by"
data={["id", "title", "username"]}
defaultValue="id"
/>
<Select
className="w-2/12"
label="Sort dir"
data={["asc", "desc"]}
defaultValue="asc"
/>
</div>
<TagsInput
label="Select properties"
data={["id", "title", "username"]}
placeholder="All selected"
/>
<TagsInput
label="Embed relations"
data={["posts", "comments"]}
placeholder="None selected"
/>
<Input.Wrapper className="w-full">
<Input.Label>Where object</Input.Label>
<div className="text-sm placeholder:text-slate-400 placeholder:opacity-80">
<JsonEditor basicSetup={{ lineNumbers: false, foldGutter: false }} />
</div>
</Input.Wrapper>
</DefaultNode.Content>
</DefaultNode>
);
};

View File

@@ -0,0 +1,65 @@
import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { useRef } from "react";
import { TbPlus, TbTrash } from "react-icons/tb";
import { Button } from "../../../../components/buttons/Button";
import * as Formy from "../../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../../components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../../../layouts/AppShell/AppShell";
class CfJsonSchemaValidator {}
export default function JsonFormTest() {
const schema: RJSFSchema = {
type: "object",
properties: {
name: {
type: "string",
title: "Name",
minLength: 3
},
variants: {
anyOf: [{ type: "string" }, { type: "number" }]
}
}
};
const ref = useRef<JsonSchemaFormRef>(null);
function onSubmit() {
console.log("submit", ref.current?.formData());
console.log("isvalid", ref.current?.validateForm());
}
return (
<>
<AppShell.SectionHeader
right={
<Button type="button" variant="primary" onClick={onSubmit}>
Submit
</Button>
}
>
JSON Schema
</AppShell.SectionHeader>
<div>
<div className="flex flex-grow flex-col gap-3 p-3">
<Formy.Group>
<Formy.Label htmlFor="name">Name</Formy.Label>
<Formy.Input id="name" name="name" />
</Formy.Group>
<Formy.Group>
<JsonSchemaForm ref={ref} schema={schema} />
</Formy.Group>
<Formy.Group>
<Formy.Label htmlFor="name">Options</Formy.Label>
<Formy.Select id="options" name="options" />
</Formy.Group>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,15 @@
import { TextInput } from "@mantine/core";
import { LiquidJsEditor } from "../../../components/code/LiquidJsEditor";
import * as Formy from "../../../components/form/Formy";
export function LiquidJsTest() {
return (
<div className="flex flex-col p-4 gap-3">
<h1>LiquidJsTest</h1>
<LiquidJsEditor />
<TextInput />
<Formy.Input />
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Button, Modal, Switch, Tooltip, useMantineColorScheme } from "@mantine/core";
import { useColorScheme, useDisclosure } from "@mantine/hooks";
import { Button as AppButton } from "../../../components/buttons/Button";
export default function MantineTest() {
return (
<div className="p-4 flex flex-col gap-2 items-start">
<h1>mantine</h1>
<div className="flex flex-row gap-2 justify-center content-center items-center">
<Button color="blue">Mantine</Button>
<AppButton>Button</AppButton>
<AppButton variant="primary">Button</AppButton>
</div>
<MantineModal />
<MantineTooltip />
<Switch defaultChecked label="I agree to sell my privacy" />
</div>
);
}
function MantineModal() {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Modal opened={opened} onClose={close} title="Authentication">
<div>Modal content</div>
</Modal>
<Button onClick={open}>Open modal</Button>
</>
);
}
function MantineTooltip() {
const { colorScheme } = useMantineColorScheme();
return (
<Tooltip label="Tooltip">
<span>Hover me ({colorScheme})</span>
</Tooltip>
);
}

View File

@@ -0,0 +1,17 @@
import { useDisclosure } from "@mantine/hooks";
import { Modal } from "../../../components/overlay/Modal";
export default function ModalTest() {
const [opened, { open, close }] = useDisclosure(true);
return (
<div className="flex flex-col w-full h-full justify-center items-center">
<button onClick={open}>Open</button>
<Modal open={opened} onClose={close}>
<div className="border-blue-500 border-2 p-10">
Modal content <button onClick={close}>close</button>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import type { Schema } from "@cfworker/json-schema";
import { useState } from "react";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
const schema: Schema = {
definitions: {
primitive: {
anyOf: [
{
title: "String",
type: "string",
},
{
title: "Number",
type: "number",
},
{
title: "Boolean",
type: "boolean",
},
],
},
numeric: {
anyOf: [
{
title: "Number",
type: "number",
},
{
title: "Datetime",
type: "string",
format: "date-time",
},
{
title: "Date",
type: "string",
format: "date",
},
{
title: "Time",
type: "string",
format: "time",
},
],
},
boolean: {
title: "Boolean",
type: "boolean",
},
},
type: "object",
properties: {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
},
conditions: {
type: "array",
items: {
type: "object",
properties: {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
},
key: {
type: "string",
},
operator: {
type: "array",
items: {
anyOf: [
{
title: "Equals",
type: "object",
properties: {
$eq: {
$ref: "#/definitions/primitive",
},
},
required: ["$eq"],
},
{
title: "Lower than",
type: "object",
properties: {
$lt: {
$ref: "#/definitions/numeric",
},
},
required: ["$lt"],
},
{
title: "Greather than",
type: "object",
properties: {
$gt: {
$ref: "#/definitions/numeric",
},
},
required: ["$gt"],
},
{
title: "Between",
type: "object",
properties: {
$between: {
type: "array",
items: {
$ref: "#/definitions/numeric",
},
minItems: 2,
maxItems: 2,
},
},
required: ["$between"],
},
{
title: "In",
type: "object",
properties: {
$in: {
type: "array",
items: {
$ref: "#/definitions/primitive",
},
minItems: 1,
},
},
},
],
},
minItems: 1,
},
},
required: ["key", "operator"],
},
minItems: 1,
},
},
required: ["operand", "conditions"],
};
export default function QueryJsonFormTest() {
const [data, setData] = useState(null);
return (
<Scrollable>
<div className="flex flex-col gap-3 p-3">
<JsonSchemaForm schema={schema} onChange={setData as any} />
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
</Scrollable>
);
}

View File

@@ -0,0 +1,48 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput } from "@mantine/core";
import { Type } from "@sinclair/typebox";
import { useForm } from "react-hook-form";
const schema = Type.Object({
example: Type.Optional(Type.String()),
exampleRequired: Type.String({ minLength: 2 })
});
export default function ReactHookErrors() {
const {
register,
handleSubmit,
watch,
formState: { errors }
} = useForm({
resolver: typeboxResolver(schema)
});
const onSubmit = (data) => console.log(data);
console.log(watch("example")); // watch input value by passing the name of it
console.log("errors", errors);
return (
/* "handleSubmit" will validate your inputs before invoking "onSubmit" */
<form onSubmit={handleSubmit(onSubmit)}>
{/* register your input into the hook by invoking the "register" function */}
<TextInput defaultValue="test" {...register("example")} />
{/* @ts-ignore include validation with required or other standard HTML validation rules */}
<TextInput {...register("exampleRequired")} error={errors.exampleRequired?.message} />
<div>
<input type="submit" />
</div>
<div>
{Object.entries(errors).map(([key, value]) => (
<p key={key}>
{/* @ts-ignore */}
{key}: {value.message}
</p>
))}
</div>
</form>
);
}

View File

@@ -0,0 +1,15 @@
import { ReactFlow } from "@xyflow/react";
const initialNodes = [
{ id: "1", position: { x: 0, y: 0 }, data: { label: "1" } },
{ id: "2", position: { x: 0, y: 100 }, data: { label: "2" } }
];
const initialEdges = [{ id: "e1-2", source: "1", target: "2" }];
export default function ReactFlowTest() {
return (
<div style={{ width: "100vw", height: "100vh" }}>
<ReactFlow nodes={initialNodes} edges={initialEdges} />
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useBknd } from "../../../client/BkndProvider";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
function useSchema() {
const [schema, setSchema] = useState<any>();
useEffect(() => {
if (schema) return;
fetch("/api/system/schema")
.then((res) => res.json())
.then((data) => setSchema(data));
}, []);
return schema;
}
const uiSchema = {
auth: {
jwt: {
basepath: {
"ui:options": {
label: false
}
},
fields: {
"ui:options": {
label: false,
orderable: false
}
}
},
strategies: {
additionalProperties: {
"ui:widget": "select",
type: {
"ui:widget": "hidden"
}
},
type: {
"ui:widget": "hidden"
}
}
},
server: {
cors: {
allow_methods: {
"ui:widget": "checkboxes"
},
allow_headers: {
"ui:options": {
orderable: false
}
}
}
},
media: {
adapter: {
"ui:options": {
label: false
},
type: {
"ui:widget": "hidden"
}
}
}
};
export default function SchemaTest() {
const { app, schema } = useBknd();
const keys = ["auth", "server", "media", "data"] as const;
const [tab, setTab] = useState(keys[0]);
console.log("schema", schema, app.config);
if (!schema) return;
const current = {
key: tab,
schema: schema[tab],
uiSchema: uiSchema[tab] || {},
config: app.config[tab]
};
console.log("current", current);
return (
<Scrollable>
<div className="flex flex-col gap-2 p-3">
<div className="flex flex-row gap-2">
{keys.map((key) => (
<button
key={key}
role="button"
className={twMerge("flex py-2 px-3", key === tab && "bg-primary/5")}
onClick={() => setTab(key as any)}
>
{key}
</button>
))}
</div>
<JsonSchemaForm
schema={current.schema}
formData={current.config}
uiSchema={current.uiSchema}
onChange={console.log}
className="legacy"
/>
</div>
</Scrollable>
);
}

View File

@@ -0,0 +1,107 @@
import {
DragDropContext,
Draggable,
type DraggableProvided,
type DraggableRubric,
type DraggableStateSnapshot,
Droppable,
type DroppableProps
} from "@hello-pangea/dnd";
import { useListState } from "@mantine/hooks";
import { IconGripVertical } from "@tabler/icons-react";
import type React from "react";
import { useEffect } from "react";
import { useId } from "react";
export default function SortableTest() {
return (
<div>
sortable
<div className="p-4">
<SortableList
data={[
{ id: "C", name: "Carbon" },
{ id: "N", name: "Nitrogen" },
{ id: "Y", name: "Yttrium" },
{ id: "Ba", name: "Barium" },
{ id: "Ce", name: "Cerium" }
]}
onReordered={(...args) => console.log("reordered", args)}
onChange={(data) => console.log("changed", data)}
/>
</div>
</div>
);
}
type SortableItemProps<Item = any> = {
item: Item;
dnd: { provided: DraggableProvided; snapshot: DraggableStateSnapshot; rubic: DraggableRubric };
};
type SortableListProps<Item = any> = {
data: Item[];
extractId?: (item: Item) => string;
renderItem?: (props: SortableItemProps<Item>) => React.ReactNode;
dndProps?: Omit<DroppableProps, "children">;
onReordered?: (from: number, to: number) => void;
onChange?: (data: Item[]) => void;
};
export function SortableList({
data = [],
extractId,
renderItem,
dndProps = { droppableId: "sortable-list", direction: "vertical" },
onReordered,
onChange
}: SortableListProps) {
const [state, handlers] = useListState(data);
function onDragEnd({ destination, source }) {
const change = { from: source.index, to: destination?.index || 0 };
handlers.reorder(change);
onReordered?.(change.from, change.to);
}
useEffect(() => {
onChange?.(state);
}, [state]);
const items = state.map((item, index) => {
const id = extractId ? extractId(item) : useId();
return (
<Draggable key={id} index={index} draggableId={id}>
{(provided, snapshot, rubic) =>
renderItem ? (
renderItem({ ...item, dnd: { provided, snapshot, rubic } })
) : (
<div
className="flex flex-row gap-2 p-2 border border-gray-200 rounded-md mb-3 bg-white items-center"
ref={provided.innerRef}
{...provided.draggableProps}
>
<div {...provided.dragHandleProps}>
<IconGripVertical className="size-5" stroke={1.5} />
</div>
<p>{JSON.stringify(item)}</p>
</div>
)
}
</Draggable>
);
});
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable {...dndProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{items}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
export function SqlAiTest() {
const [answer, setAnswer] = useState<any>([]);
const [loading, setLoading] = useState(false);
async function handleStart() {
if (loading) return;
setAnswer([]);
setLoading(true);
const source = new EventSource("/api/system/test/sql");
source.onmessage = (event) => {
if (event.data === "[DONE]") {
setLoading(false);
source.close();
return;
}
const data = JSON.parse(event.data);
setAnswer((prev) => [...prev, data]);
console.log("data", data);
};
}
return (
<div className="flex flex-col gap-2 p-4">
<h1>ai sql test</h1>
<button onClick={handleStart}>{loading ? "..." : "start"}</button>
<div>
{answer.map((item, key) => (
<span key={key}>{item.response}</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function SwaggerUI() {
useEffect(() => {
// Create a script element to load the Swagger UI bundle
const script = document.createElement("script");
script.src = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js";
script.crossOrigin = "anonymous";
script.async = true;
// Append the script to the body and set up Swagger UI once loaded
script.onload = () => {
// @ts-ignore
if (window.SwaggerUIBundle) {
// @ts-ignore
window.ui = window.SwaggerUIBundle({
url: "http://localhost:28623/api/system/openapi.json",
dom_id: "#swagger-ui"
});
}
};
document.body.appendChild(script);
// Cleanup script on unmount
return () => {
document.body.removeChild(script);
};
}, []);
return (
<>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
<Scrollable>
<div id="swagger-ui" />
</Scrollable>
</>
);
}
export default SwaggerUI;