mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
public commit
This commit is contained in:
60
app/src/ui/routes/auth/_auth.root.tsx
Normal file
60
app/src/ui/routes/auth/_auth.root.tsx
Normal 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" />;
|
||||
}
|
||||
92
app/src/ui/routes/auth/auth.index.tsx
Normal file
92
app/src/ui/routes/auth/auth.index.tsx
Normal 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>
|
||||
);
|
||||
121
app/src/ui/routes/auth/auth.login.tsx
Normal file
121
app/src/ui/routes/auth/auth.login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
app/src/ui/routes/auth/auth.roles.edit.$role.tsx
Normal file
90
app/src/ui/routes/auth/auth.roles.edit.$role.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
app/src/ui/routes/auth/auth.roles.tsx
Normal file
128
app/src/ui/routes/auth/auth.roles.tsx
Normal 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} />;
|
||||
};
|
||||
79
app/src/ui/routes/auth/auth.settings.tsx
Normal file
79
app/src/ui/routes/auth/auth.settings.tsx
Normal 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() {}
|
||||
57
app/src/ui/routes/auth/auth.strategies.tsx
Normal file
57
app/src/ui/routes/auth/auth.strategies.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
app/src/ui/routes/auth/auth.users.tsx
Normal file
3
app/src/ui/routes/auth/auth.users.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function AuthUsersList() {
|
||||
return null;
|
||||
}
|
||||
152
app/src/ui/routes/auth/forms/role.form.tsx
Normal file
152
app/src/ui/routes/auth/forms/role.form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
app/src/ui/routes/auth/index.tsx
Normal file
21
app/src/ui/routes/auth/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
app/src/ui/routes/data/_data.root.tsx
Normal file
136
app/src/ui/routes/data/_data.root.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
296
app/src/ui/routes/data/data.$entity.$id.tsx
Normal file
296
app/src/ui/routes/data/data.$entity.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/src/ui/routes/data/data.$entity.create.tsx
Normal file
103
app/src/ui/routes/data/data.$entity.create.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
app/src/ui/routes/data/data.$entity.index.tsx
Normal file
145
app/src/ui/routes/data/data.$entity.index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
233
app/src/ui/routes/data/data.schema.$entity.tsx
Normal file
233
app/src/ui/routes/data/data.schema.$entity.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
app/src/ui/routes/data/data.schema.index.tsx
Normal file
41
app/src/ui/routes/data/data.schema.index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
454
app/src/ui/routes/data/forms/entity.fields.form.tsx
Normal file
454
app/src/ui/routes/data/forms/entity.fields.form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/src/ui/routes/data/index.tsx
Normal file
25
app/src/ui/routes/data/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/src/ui/routes/flows/_flows.root.tsx
Normal file
67
app/src/ui/routes/flows/_flows.root.tsx
Normal 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!" />;
|
||||
}
|
||||
136
app/src/ui/routes/flows/components/FlowCreateModal.tsx
Normal file
136
app/src/ui/routes/flows/components/FlowCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
app/src/ui/routes/flows/flows.edit.$name.tsx
Normal file
241
app/src/ui/routes/flows/flows.edit.$name.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
app/src/ui/routes/flows/flows.list.tsx
Normal file
72
app/src/ui/routes/flows/flows.list.tsx
Normal 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} />;
|
||||
};
|
||||
15
app/src/ui/routes/flows/index.tsx
Normal file
15
app/src/ui/routes/flows/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
app/src/ui/routes/flows_old/_flows.root.tsx
Normal file
64
app/src/ui/routes/flows_old/_flows.root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
app/src/ui/routes/flows_old/flow.$key.tsx
Normal file
202
app/src/ui/routes/flows_old/flow.$key.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
app/src/ui/routes/flows_old/index.tsx
Normal file
12
app/src/ui/routes/flows_old/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
app/src/ui/routes/index.tsx
Normal file
81
app/src/ui/routes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
app/src/ui/routes/media/_media.root.tsx
Normal file
99
app/src/ui/routes/media/_media.root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
app/src/ui/routes/media/index.tsx
Normal file
10
app/src/ui/routes/media/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/src/ui/routes/root.tsx
Normal file
52
app/src/ui/routes/root.tsx
Normal 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.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
358
app/src/ui/routes/settings/components/Setting.tsx
Normal file
358
app/src/ui/routes/settings/components/Setting.tsx
Normal 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
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
170
app/src/ui/routes/settings/components/SettingNewModal.tsx
Normal file
170
app/src/ui/routes/settings/components/SettingNewModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
73
app/src/ui/routes/settings/components/SettingSchemaModal.tsx
Normal file
73
app/src/ui/routes/settings/components/SettingSchemaModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
186
app/src/ui/routes/settings/index.tsx
Normal file
186
app/src/ui/routes/settings/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
156
app/src/ui/routes/settings/routes/auth.settings.tsx
Normal file
156
app/src/ui/routes/settings/routes/auth.settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
347
app/src/ui/routes/settings/routes/data.settings.tsx
Normal file
347
app/src/ui/routes/settings/routes/data.settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
212
app/src/ui/routes/settings/routes/flows.settings.tsx
Normal file
212
app/src/ui/routes/settings/routes/flows.settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
app/src/ui/routes/settings/utils/schema.ts
Normal file
50
app/src/ui/routes/settings/utils/schema.ts
Normal 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];
|
||||
}
|
||||
81
app/src/ui/routes/test/index.tsx
Normal file
81
app/src/ui/routes/test/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
app/src/ui/routes/test/tests/appshell-accordions-test.tsx
Normal file
124
app/src/ui/routes/test/tests/appshell-accordions-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/src/ui/routes/test/tests/dropdown-test.tsx
Normal file
17
app/src/ui/routes/test/tests/dropdown-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
app/src/ui/routes/test/tests/entity-fields-form.tsx
Normal file
296
app/src/ui/routes/test/tests/entity-fields-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
app/src/ui/routes/test/tests/flow-create-schema-test.tsx
Normal file
25
app/src/ui/routes/test/tests/flow-create-schema-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/src/ui/routes/test/tests/flow-form-test.tsx
Normal file
18
app/src/ui/routes/test/tests/flow-form-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
app/src/ui/routes/test/tests/flows-test.tsx
Normal file
243
app/src/ui/routes/test/tests/flows-test.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
65
app/src/ui/routes/test/tests/jsonform-test/index.tsx
Normal file
65
app/src/ui/routes/test/tests/jsonform-test/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
app/src/ui/routes/test/tests/liquid-js-test.tsx
Normal file
15
app/src/ui/routes/test/tests/liquid-js-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
app/src/ui/routes/test/tests/mantine-test.tsx
Normal file
42
app/src/ui/routes/test/tests/mantine-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/src/ui/routes/test/tests/modal-test.tsx
Normal file
17
app/src/ui/routes/test/tests/modal-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
app/src/ui/routes/test/tests/query-jsonform.tsx
Normal file
158
app/src/ui/routes/test/tests/query-jsonform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/src/ui/routes/test/tests/react-hook-errors.tsx
Normal file
48
app/src/ui/routes/test/tests/react-hook-errors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
app/src/ui/routes/test/tests/reactflow-test.tsx
Normal file
15
app/src/ui/routes/test/tests/reactflow-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
app/src/ui/routes/test/tests/schema-test.tsx
Normal file
113
app/src/ui/routes/test/tests/schema-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
app/src/ui/routes/test/tests/sortable-test.tsx
Normal file
107
app/src/ui/routes/test/tests/sortable-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/src/ui/routes/test/tests/sql-ai-test.tsx
Normal file
37
app/src/ui/routes/test/tests/sql-ai-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
app/src/ui/routes/test/tests/swagger-test.tsx
Normal file
42
app/src/ui/routes/test/tests/swagger-test.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user