mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 20:37:21 +00:00
public commit
This commit is contained in:
36
app/src/ui/Admin.tsx
Normal file
36
app/src/ui/Admin.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import React from "react";
|
||||
import { BkndProvider, ClientProvider, useBknd } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { BkndModalsProvider } from "./modals";
|
||||
import { Routes } from "./routes";
|
||||
|
||||
export default function Admin({
|
||||
baseUrl: baseUrlOverride,
|
||||
withProvider = false
|
||||
}: { baseUrl?: string; withProvider?: boolean }) {
|
||||
const Component = (
|
||||
<BkndProvider>
|
||||
<AdminInternal />
|
||||
</BkndProvider>
|
||||
);
|
||||
return withProvider ? (
|
||||
<ClientProvider baseUrl={baseUrlOverride}>{Component}</ClientProvider>
|
||||
) : (
|
||||
Component
|
||||
);
|
||||
}
|
||||
|
||||
function AdminInternal() {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
||||
<Notifications />
|
||||
<BkndModalsProvider>
|
||||
<Routes />
|
||||
</BkndModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
BIN
app/src/ui/assets/favicon.ico
Normal file
BIN
app/src/ui/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
83
app/src/ui/client/BkndProvider.tsx
Normal file
83
app/src/ui/client/BkndProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
||||
import { useClient } from "./ClientProvider";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
type BkndContext = {
|
||||
version: number;
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
};
|
||||
|
||||
const BkndContext = createContext<BkndContext>(undefined!);
|
||||
export type { TSchemaActions };
|
||||
|
||||
export function BkndProvider({
|
||||
includeSecrets = false,
|
||||
children
|
||||
}: { includeSecrets?: boolean; children: any }) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] = useState<BkndContext>();
|
||||
const client = useClient();
|
||||
|
||||
async function fetchSchema(_includeSecrets: boolean = false) {
|
||||
if (withSecrets) return;
|
||||
const { body } = await client.api.system.readSchema({
|
||||
config: true,
|
||||
secrets: _includeSecrets
|
||||
});
|
||||
console.log("--schema fetched", body);
|
||||
setSchema(body as any);
|
||||
setWithSecrets(_includeSecrets);
|
||||
}
|
||||
|
||||
async function requireSecrets() {
|
||||
if (withSecrets) return;
|
||||
await fetchSchema(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (schema?.schema) return;
|
||||
fetchSchema(includeSecrets);
|
||||
}, []);
|
||||
|
||||
if (!schema?.schema) return null;
|
||||
const app = new AppReduced(schema.config as any);
|
||||
|
||||
const actions = getSchemaActions({ client, setSchema });
|
||||
|
||||
return (
|
||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>
|
||||
{children}
|
||||
</BkndContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
||||
const ctx = useContext(BkndContext);
|
||||
if (withSecrets) ctx.requireSecrets();
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/*
|
||||
type UseSchemaForType<Key extends keyof ModuleSchemas> = {
|
||||
version: number;
|
||||
schema: ModuleSchemas[Key];
|
||||
config: ModuleConfigs[Key];
|
||||
};
|
||||
|
||||
export function useSchemaFor<Key extends keyof ModuleConfigs>(module: Key): UseSchemaForType<Key> {
|
||||
//const app = useApp();
|
||||
const { version, schema, config } = useSchema();
|
||||
return {
|
||||
version,
|
||||
schema: schema[module],
|
||||
config: config[module]
|
||||
};
|
||||
}*/
|
||||
82
app/src/ui/client/ClientProvider.tsx
Normal file
82
app/src/ui/client/ClientProvider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { AppQueryClient } from "./utils/AppQueryClient";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
||||
baseUrl: undefined
|
||||
} as any);
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?: string }) => {
|
||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
|
||||
try {
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
if (_ctx_baseUrl) {
|
||||
console.warn("wrapped many times");
|
||||
setActualBaseUrl(_ctx_baseUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Only set base URL if running on the client side
|
||||
if (typeof window !== "undefined") {
|
||||
setActualBaseUrl(baseUrl || window.location.origin);
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
if (!actualBaseUrl) {
|
||||
// Optionally, return a fallback during SSR rendering
|
||||
return null; // or a loader/spinner if desired
|
||||
}
|
||||
|
||||
console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl });
|
||||
const client = createClient(actualBaseUrl);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientContext.Provider value={{ baseUrl: actualBaseUrl, client }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function createClient(baseUrl: string = window.location.origin) {
|
||||
return new AppQueryClient(baseUrl);
|
||||
}
|
||||
|
||||
export function createOrUseClient(baseUrl: string = window.location.origin) {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
console.warn("createOrUseClient returned a new client");
|
||||
return createClient(baseUrl);
|
||||
}
|
||||
|
||||
return context.client;
|
||||
}
|
||||
|
||||
export const useClient = () => {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
|
||||
console.log("useClient", context.baseUrl);
|
||||
return context.client;
|
||||
};
|
||||
|
||||
export const useBaseUrl = () => {
|
||||
const context = useContext(ClientContext);
|
||||
return context.baseUrl;
|
||||
};
|
||||
4
app/src/ui/client/index.ts
Normal file
4
app/src/ui/client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ClientProvider, useClient, useBaseUrl } from "./ClientProvider";
|
||||
export { BkndProvider, useBknd } from "./BkndProvider";
|
||||
|
||||
export { useAuth } from "./schema/auth/use-auth";
|
||||
190
app/src/ui/client/schema/actions.ts
Normal file
190
app/src/ui/client/schema/actions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { set } from "lodash-es";
|
||||
import type { ModuleConfigs } from "../../../modules";
|
||||
import type { AppQueryClient } from "../utils/AppQueryClient";
|
||||
|
||||
export type SchemaActionsProps = {
|
||||
client: AppQueryClient;
|
||||
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
||||
};
|
||||
|
||||
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
||||
|
||||
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
|
||||
const baseUrl = client.baseUrl;
|
||||
const token = client.auth().state()?.token;
|
||||
return {
|
||||
set: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
value: ModuleConfigs[Module],
|
||||
force?: boolean
|
||||
) => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config set", module, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
patch: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
): Promise<boolean> => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config patch", module, path, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
overwrite: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config overwrite", module, path, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
add: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config add", module, data);
|
||||
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
remove: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config remove", module, data);
|
||||
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
107
app/src/ui/client/schema/auth/use-auth.ts
Normal file
107
app/src/ui/client/schema/auth/use-auth.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Api } from "Api";
|
||||
import type { AuthResponse } from "auth";
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import type { ApiResponse } from "modules/ModuleApi";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createClient,
|
||||
createOrUseClient,
|
||||
queryClient,
|
||||
useBaseUrl,
|
||||
useClient
|
||||
} from "../../ClientProvider";
|
||||
|
||||
type LoginData = {
|
||||
email: string;
|
||||
password: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type UseAuth = {
|
||||
data: (AuthResponse & { verified: boolean }) | undefined;
|
||||
user: AuthResponse["user"] | undefined;
|
||||
token: AuthResponse["token"] | undefined;
|
||||
verified: boolean;
|
||||
login: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
||||
register: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
||||
logout: () => void;
|
||||
verify: () => void;
|
||||
setToken: (token: string) => void;
|
||||
};
|
||||
|
||||
// @todo: needs to use a specific auth endpoint to get strategy information
|
||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
const ctxBaseUrl = useBaseUrl();
|
||||
//const client = useClient();
|
||||
const client = createOrUseClient(options?.baseUrl ? options?.baseUrl : ctxBaseUrl);
|
||||
const authState = client.auth().state();
|
||||
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||
const verified = authState?.verified ?? false;
|
||||
|
||||
async function login(input: LoginData) {
|
||||
const res = await client.auth().login(input);
|
||||
if (res.res.ok && res.data && "user" in res.data) {
|
||||
setAuthData(res.data);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function register(input: LoginData) {
|
||||
const res = await client.auth().register(input);
|
||||
if (res.res.ok && res.data && "user" in res.data) {
|
||||
setAuthData(res.data);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
setAuthData(client.auth().setToken(token) as any);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await client.auth().logout();
|
||||
setAuthData(undefined);
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await client.auth().verify();
|
||||
setAuthData(client.auth().state());
|
||||
}
|
||||
|
||||
return {
|
||||
data: authData,
|
||||
user: authData?.user,
|
||||
token: authData?.token,
|
||||
verified,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
setToken,
|
||||
verify
|
||||
};
|
||||
};
|
||||
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): {
|
||||
strategies: AppAuthSchema["strategies"];
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [strategies, setStrategies] = useState<AppAuthSchema["strategies"]>();
|
||||
const ctxBaseUrl = useBaseUrl();
|
||||
const api = new Api({
|
||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setStrategies(res.body.strategies);
|
||||
}
|
||||
})();
|
||||
}, [options?.baseUrl]);
|
||||
|
||||
return { strategies, loading: !strategies };
|
||||
};
|
||||
33
app/src/ui/client/schema/auth/use-bknd-auth.ts
Normal file
33
app/src/ui/client/schema/auth/use-bknd-auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useBknd } from "ui/client";
|
||||
|
||||
export function useBkndAuth() {
|
||||
//const client = useClient();
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
roles: {
|
||||
add: async (name: string, data: any = {}) => {
|
||||
console.log("add role", name, data);
|
||||
return await bkndActions.add("auth", `roles.${name}`, data);
|
||||
},
|
||||
patch: async (name: string, data: any) => {
|
||||
console.log("patch role", name, data);
|
||||
return await bkndActions.patch("auth", `roles.${name}`, data);
|
||||
},
|
||||
delete: async (name: string) => {
|
||||
console.log("delete role", name);
|
||||
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
||||
return await bkndActions.remove("auth", `roles.${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const $auth = {};
|
||||
|
||||
return {
|
||||
$auth,
|
||||
config: config.auth,
|
||||
schema: schema.auth,
|
||||
actions
|
||||
};
|
||||
}
|
||||
115
app/src/ui/client/schema/data/use-bknd-data.ts
Normal file
115
app/src/ui/client/schema/data/use-bknd-data.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import type { Entity } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import {
|
||||
type TAppDataEntity,
|
||||
type TAppDataEntityFields,
|
||||
type TAppDataField,
|
||||
type TAppDataRelation,
|
||||
entitiesSchema,
|
||||
entityFields,
|
||||
fieldsSchema,
|
||||
relationsSchema
|
||||
} from "data/data-schema";
|
||||
import { useBknd } from "ui/client";
|
||||
import type { TSchemaActions } from "ui/client/schema/actions";
|
||||
|
||||
export function useBkndData() {
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
|
||||
const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
});
|
||||
|
||||
const actions = {
|
||||
entity: {
|
||||
add: async (name: string, data: TAppDataEntity) => {
|
||||
console.log("create entity", { data });
|
||||
const validated = parse(entitiesSchema, data, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
// @todo: check for existing?
|
||||
return await bkndActions.add("data", `entities.${name}`, validated);
|
||||
},
|
||||
patch: (entityName: string) => {
|
||||
const entity = entities[entityName];
|
||||
if (!entity) {
|
||||
throw new Error(`Entity "${entityName}" not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
|
||||
console.log("patch config", entityName, partial);
|
||||
return await bkndActions.patch("data", `entities.${entityName}.config`, partial);
|
||||
},
|
||||
fields: entityFieldActions(bkndActions, entityName)
|
||||
};
|
||||
}
|
||||
},
|
||||
relations: {
|
||||
add: async (relation: TAppDataRelation) => {
|
||||
console.log("create relation", { relation });
|
||||
const name = crypto.randomUUID();
|
||||
const validated = parse(Type.Union(relationsSchema), relation, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
return await bkndActions.add("data", `relations.${name}`, validated);
|
||||
}
|
||||
}
|
||||
};
|
||||
const $data = {
|
||||
entity: (name: string) => entities[name]
|
||||
};
|
||||
|
||||
return {
|
||||
$data,
|
||||
entities,
|
||||
relations: app.relations,
|
||||
config: config.data,
|
||||
schema: schema.data,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
|
||||
return {
|
||||
add: async (name: string, field: TAppDataField) => {
|
||||
console.log("create field", { name, field });
|
||||
const validated = parse(fieldsSchema, field, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
return await bkndActions.add("data", `entities.${entityName}.fields.${name}`, validated);
|
||||
},
|
||||
patch: () => null,
|
||||
set: async (fields: TAppDataEntityFields) => {
|
||||
console.log("set fields", entityName, fields);
|
||||
try {
|
||||
const validated = parse(entityFields, fields, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
const res = await bkndActions.overwrite(
|
||||
"data",
|
||||
`entities.${entityName}.fields`,
|
||||
validated
|
||||
);
|
||||
console.log("res", res);
|
||||
//bkndActions.set("data", "entities", fields);
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
if (e instanceof TypeInvalidError) {
|
||||
alert("Error updating fields: " + e.firstToString());
|
||||
} else {
|
||||
alert("An error occured, check console. There will be nice error handling soon.");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
23
app/src/ui/client/schema/flows/use-flows.ts
Normal file
23
app/src/ui/client/schema/flows/use-flows.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type Static, parse } from "core/utils";
|
||||
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
|
||||
import { useBknd } from "../../BkndProvider";
|
||||
import { useClient } from "../../ClientProvider";
|
||||
|
||||
export function useFlows() {
|
||||
const client = useClient();
|
||||
const { config, app, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
flow: {
|
||||
create: async (name: string, data: TAppFlowSchema) => {
|
||||
console.log("would create", name, data);
|
||||
const parsed = parse(flowSchema, data, { skipMark: true, forceParse: true });
|
||||
console.log("parsed", parsed);
|
||||
const res = await bkndActions.add("flows", `flows.${name}`, parsed);
|
||||
console.log("res", res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { flows: app.flows, config: config.flows, actions };
|
||||
}
|
||||
40
app/src/ui/client/schema/system/use-bknd-system.ts
Normal file
40
app/src/ui/client/schema/system/use-bknd-system.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useBknd } from "ui/client";
|
||||
|
||||
export function useBkndSystem() {
|
||||
const { config, schema, actions: bkndActions } = useBknd();
|
||||
const theme = config.server.admin.color_scheme ?? "light";
|
||||
|
||||
const actions = {
|
||||
theme: {
|
||||
set: async (scheme: "light" | "dark") => {
|
||||
return await bkndActions.patch("server", "admin", {
|
||||
color_scheme: scheme
|
||||
});
|
||||
},
|
||||
toggle: async () => {
|
||||
return await bkndActions.patch("server", "admin", {
|
||||
color_scheme: theme === "light" ? "dark" : "light"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const $system = {};
|
||||
|
||||
return {
|
||||
$system,
|
||||
config: config.server,
|
||||
schema: schema.server,
|
||||
theme,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
export function useBkndSystemTheme() {
|
||||
const $sys = useBkndSystem();
|
||||
|
||||
return {
|
||||
theme: $sys.theme,
|
||||
set: $sys.actions.theme.set,
|
||||
toggle: () => $sys.actions.theme.toggle()
|
||||
};
|
||||
}
|
||||
8
app/src/ui/client/use-theme.ts
Normal file
8
app/src/ui/client/use-theme.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useBknd } from "ui";
|
||||
|
||||
export function useTheme(): { theme: "light" | "dark" } {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme as any;
|
||||
|
||||
return { theme };
|
||||
}
|
||||
211
app/src/ui/client/utils/AppQueryClient.ts
Normal file
211
app/src/ui/client/utils/AppQueryClient.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
type QueryObserverOptions,
|
||||
type UseQueryResult,
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery
|
||||
} from "@tanstack/react-query";
|
||||
import type { AuthResponse } from "auth";
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { Api } from "../../../Api";
|
||||
import type { ApiResponse } from "../../../modules/ModuleApi";
|
||||
import { queryClient } from "../ClientProvider";
|
||||
|
||||
export class AppQueryClient {
|
||||
api: Api;
|
||||
constructor(public baseUrl: string) {
|
||||
this.api = new Api({
|
||||
host: baseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
});
|
||||
}
|
||||
|
||||
queryOptions(options?: Partial<QueryObserverOptions>): Partial<QueryObserverOptions> {
|
||||
return {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
placeholderData: keepPreviousData,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
auth = () => {
|
||||
return {
|
||||
state: (): (AuthResponse & { verified: boolean }) | undefined => {
|
||||
return this.api.getAuthState() as any;
|
||||
},
|
||||
login: async (data: { email: string; password: string }): Promise<
|
||||
ApiResponse<AuthResponse>
|
||||
> => {
|
||||
return await this.api.auth.loginWithPassword(data);
|
||||
},
|
||||
register: async (data: any): Promise<ApiResponse<AuthResponse>> => {
|
||||
return await this.api.auth.registerWithPassword(data);
|
||||
},
|
||||
logout: async () => {
|
||||
this.api.updateToken(undefined);
|
||||
return true;
|
||||
},
|
||||
setToken: (token) => {
|
||||
this.api.updateToken(token);
|
||||
return this.api.getAuthState();
|
||||
},
|
||||
verify: async () => {
|
||||
console.log("verifiying");
|
||||
const res = await this.api.auth.me();
|
||||
console.log("verifying result", res);
|
||||
if (!res.res.ok) {
|
||||
this.api.markAuthVerified(false);
|
||||
this.api.updateToken(undefined);
|
||||
} else {
|
||||
this.api.markAuthVerified(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
media = (options?: Partial<QueryObserverOptions>) => {
|
||||
const queryOptions = this.queryOptions(options);
|
||||
return {
|
||||
api: () => {
|
||||
return this.api.media;
|
||||
},
|
||||
list: (query: Partial<RepoQuery> = { limit: 10 }): UseQueryResult<ApiResponse> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", "media", { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readMany("media", query);
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteFile: async (filename: string | { path: string }) => {
|
||||
const res = await this.api.media.deleteFile(
|
||||
typeof filename === "string" ? filename : filename.path
|
||||
);
|
||||
|
||||
if (res.res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
query = (options?: Partial<QueryObserverOptions>) => {
|
||||
const queryOptions = this.queryOptions(options);
|
||||
return {
|
||||
data: {
|
||||
entity: (name: string) => {
|
||||
return {
|
||||
readOne: (
|
||||
id: number,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
): any => {
|
||||
return useQuery({
|
||||
...queryOptions,
|
||||
queryKey: ["data", "entity", name, id, { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readOne(name, id, query);
|
||||
}
|
||||
});
|
||||
},
|
||||
readMany: (
|
||||
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
|
||||
): UseQueryResult<ApiResponse> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", name, { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readMany(name, query);
|
||||
}
|
||||
});
|
||||
},
|
||||
readManyByReference: (
|
||||
id: number,
|
||||
reference: string,
|
||||
referenced_entity?: string, // required for query invalidation
|
||||
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
|
||||
): UseQueryResult<Pick<RepositoryResponse, "meta" | "data">> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: [
|
||||
"data",
|
||||
"entity",
|
||||
referenced_entity ?? reference,
|
||||
{ name, id, reference, query }
|
||||
],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readManyByReference(
|
||||
name,
|
||||
id,
|
||||
reference,
|
||||
query
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
count: (
|
||||
where: RepoQuery["where"] = {}
|
||||
): UseQueryResult<ApiResponse<{ entity: string; count: number }>> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", name, "fn", "count", { where }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.count(name, where);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// @todo: centralize, improve
|
||||
__invalidate = (...args: any[]) => {
|
||||
console.log("___invalidate", ["data", "entity", ...args]);
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", ...args] });
|
||||
};
|
||||
|
||||
// @todo: must return response... why?
|
||||
mutation = {
|
||||
data: {
|
||||
entity: (name: string) => {
|
||||
return {
|
||||
update: (id: number): any => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: EntityData) => {
|
||||
return await this.api.data.updateOne(name, id, input);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
},
|
||||
create: (): any => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: EntityData) => {
|
||||
return await this.api.data.createOne(name, input);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
},
|
||||
delete: (id: number): any => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
return await this.api.data.deleteOne(name, id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
83
app/src/ui/client/utils/AppReduced.ts
Normal file
83
app/src/ui/client/utils/AppReduced.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { App } from "App";
|
||||
import type { Entity, EntityRelation } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
|
||||
export type AppType = ReturnType<App["toJSON"]>;
|
||||
|
||||
/**
|
||||
* Reduced version of the App class for frontend use
|
||||
*/
|
||||
export class AppReduced {
|
||||
// @todo: change to record
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _flows: Flow[] = [];
|
||||
|
||||
constructor(protected appJson: AppType) {
|
||||
console.log("received appjson", appJson);
|
||||
|
||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
});
|
||||
|
||||
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
|
||||
return AppData.constructRelation(relation, this.entity.bind(this));
|
||||
});
|
||||
|
||||
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
|
||||
// @ts-ignore
|
||||
// @todo: fix constructing flow
|
||||
const flow = Flow.fromObject(name, obj, TaskMap);
|
||||
|
||||
this._flows.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
get entities(): Entity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
// @todo: change to record
|
||||
entity(_entity: Entity | string): Entity {
|
||||
const name = typeof _entity === "string" ? _entity : _entity.name;
|
||||
const entity = this._entities.find((entity) => entity.name === name);
|
||||
if (!entity) {
|
||||
throw new Error(`Entity "${name}" not found`);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
get relations(): RelationAccessor {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get flows(): Flow[] {
|
||||
return this._flows;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.appJson;
|
||||
}
|
||||
|
||||
getAdminConfig() {
|
||||
return this.appJson.server.admin;
|
||||
}
|
||||
|
||||
getSettingsPath(path: string[] = []): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
return [base, ...path].join("/");
|
||||
}
|
||||
|
||||
getAbsolutePath(path?: string): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
getAuthConfig() {
|
||||
return this.appJson.auth;
|
||||
}
|
||||
}
|
||||
28
app/src/ui/client/utils/theme-switcher.ts
Normal file
28
app/src/ui/client/utils/theme-switcher.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
export type AppTheme = "light" | "dark" | string;
|
||||
|
||||
export function useSetTheme(initialTheme: AppTheme = "light") {
|
||||
const [theme, _setTheme] = useState(initialTheme);
|
||||
|
||||
const $html = document.querySelector("#bknd-admin")!;
|
||||
function setTheme(newTheme: AppTheme) {
|
||||
$html?.classList.remove("dark", "light");
|
||||
$html?.classList.add(newTheme);
|
||||
_setTheme(newTheme);
|
||||
|
||||
// @todo: just a quick switcher config update test
|
||||
fetch("/api/system/config/patch/server/admin", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ color_scheme: newTheme })
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("theme updated", data);
|
||||
});
|
||||
}
|
||||
|
||||
return { theme, setTheme };
|
||||
}
|
||||
17
app/src/ui/components/Context.tsx
Normal file
17
app/src/ui/components/Context.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useBaseUrl } from "../client/ClientProvider";
|
||||
|
||||
export function Context() {
|
||||
const baseurl = useBaseUrl();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{JSON.stringify(
|
||||
{
|
||||
baseurl
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/src/ui/components/buttons/Button.tsx
Normal file
75
app/src/ui/components/buttons/Button.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type React from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
small: "px-2 py-1.5 rounded-md gap-1.5 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-2.5",
|
||||
large: "px-4 py-3 rounded-md gap-3 text-lg"
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 15,
|
||||
default: 18,
|
||||
large: 22
|
||||
};
|
||||
|
||||
const styles = {
|
||||
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
|
||||
primary: "bg-primary hover:bg-primary/80 link text-background",
|
||||
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
||||
subtlered:
|
||||
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
|
||||
};
|
||||
|
||||
export type BaseProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
IconLeft?: React.ComponentType<any>;
|
||||
IconRight?: React.ComponentType<any>;
|
||||
iconSize?: number;
|
||||
iconProps?: Record<string, any>;
|
||||
size?: keyof typeof sizes;
|
||||
variant?: keyof typeof styles;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
const Base = ({
|
||||
children,
|
||||
size,
|
||||
variant,
|
||||
IconLeft,
|
||||
IconRight,
|
||||
iconSize = iconSizes[size ?? "default"],
|
||||
iconProps,
|
||||
labelClassName,
|
||||
...props
|
||||
}: BaseProps) => ({
|
||||
...props,
|
||||
className: twMerge(
|
||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed",
|
||||
sizes[size ?? "default"],
|
||||
styles[variant ?? "default"],
|
||||
props.className
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{IconLeft && <IconLeft size={iconSize} {...iconProps} />}
|
||||
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>}
|
||||
{IconRight && <IconRight size={iconSize} {...iconProps} />}
|
||||
</>
|
||||
)
|
||||
});
|
||||
|
||||
export type ButtonProps = React.ComponentPropsWithoutRef<"button"> & BaseProps;
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => (
|
||||
<button type="button" ref={ref} {...Base(props)} />
|
||||
));
|
||||
|
||||
export type ButtonLinkProps = React.ComponentPropsWithoutRef<"a"> & BaseProps & { href: string };
|
||||
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => (
|
||||
<Link ref={ref} href="#" {...Base(props)} />
|
||||
));
|
||||
42
app/src/ui/components/buttons/IconButton.tsx
Normal file
42
app/src/ui/components/buttons/IconButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Icon, IconProps } from "@tabler/icons-react";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import type { IconType as RI_IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
|
||||
export type IconType =
|
||||
| RI_IconType
|
||||
| React.ForwardRefExoticComponent<IconProps & React.RefAttributes<Icon>>;
|
||||
|
||||
const styles = {
|
||||
xs: { className: "p-0.5", size: 13 },
|
||||
sm: { className: "p-0.5", size: 16 },
|
||||
md: { className: "p-1", size: 20 },
|
||||
lg: { className: "p-1.5", size: 24 }
|
||||
} as const;
|
||||
|
||||
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
Icon: IconType;
|
||||
iconProps?: Record<string, any>;
|
||||
variant?: ButtonProps["variant"];
|
||||
size?: keyof typeof styles;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, ...rest }, ref) => {
|
||||
const style = styles[size ?? "md"];
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
iconSize={style.size}
|
||||
iconProps={iconProps}
|
||||
IconLeft={Icon}
|
||||
className={twMerge(style.className, rest.className)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type MiniMapProps,
|
||||
ReactFlow,
|
||||
type ReactFlowProps,
|
||||
ReactFlowProvider,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow
|
||||
} from "@xyflow/react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: JSX.Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
nodes: _nodes,
|
||||
edges: _edges,
|
||||
externalProvider,
|
||||
backgroundStyle = "lines",
|
||||
minimap = false,
|
||||
children,
|
||||
onDropNewNode,
|
||||
onDropNewEdge,
|
||||
...props
|
||||
}: CanvasProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPointerPressed, setIsPointerPressed] = useState(false);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey) {
|
||||
setIsCommandPressed(true);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
//event.preventDefault(); // Prevent default space scrolling behavior
|
||||
setIsSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey) {
|
||||
setIsCommandPressed(false);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
setIsSpacePressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(true);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector("html")?.classList.add("fixed");
|
||||
|
||||
// Add global key listeners
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
// Add global pointer listeners
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
|
||||
// Cleanup event listeners on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
document.querySelector("html")?.classList.remove("fixed");
|
||||
};
|
||||
}, []);
|
||||
|
||||
//console.log("mode", { cmd: isCommandPressed, space: isSpacePressed, mouse: isPointerPressed });
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(_nodes ?? []);
|
||||
setEdges(_edges ?? []);
|
||||
}, [_nodes, _edges]);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
|
||||
|
||||
const onConnectEnd = useCallback(
|
||||
(event, connectionState) => {
|
||||
if (!onDropNewNode || !onDropNewEdge) return;
|
||||
|
||||
const { fromNode, fromHandle, fromPosition } = connectionState;
|
||||
// when a connection is dropped on the pane it's not valid
|
||||
if (!connectionState.isValid) {
|
||||
console.log("conn", { event, connectionState });
|
||||
// we need to remove the wrapper bounds, in order to get the correct position
|
||||
|
||||
const { clientX, clientY } =
|
||||
"changedTouches" in event ? event.changedTouches[0] : event;
|
||||
const newNode = onDropNewNode({
|
||||
id: "select",
|
||||
type: "default",
|
||||
data: { label: "" },
|
||||
position: screenToFlowPosition({
|
||||
x: clientX,
|
||||
y: clientY
|
||||
}),
|
||||
origin: [0.0, 0.0]
|
||||
});
|
||||
|
||||
setNodes((nds) => nds.concat(newNode as any));
|
||||
setEdges((eds) =>
|
||||
eds.concat(
|
||||
onDropNewNode({
|
||||
id: newNode.id,
|
||||
source: connectionState.fromNode.id,
|
||||
target: newNode.id
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition]
|
||||
);
|
||||
//console.log("edges1", edges);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
colorMode={theme}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
className={
|
||||
isCommandPressed
|
||||
? "cursor-zoom-in"
|
||||
: isSpacePressed
|
||||
? isPointerPressed
|
||||
? "cursor-grabbing"
|
||||
: "cursor-grab"
|
||||
: ""
|
||||
}
|
||||
proOptions={{
|
||||
hideAttribution: true
|
||||
}}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1.5,
|
||||
...props.fitViewOptions
|
||||
}}
|
||||
nodeDragThreshold={25}
|
||||
panOnScrollSpeed={1}
|
||||
snapToGrid
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodesConnectable={false}
|
||||
/*panOnDrag={isSpacePressed}*/
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={isCommandPressed}
|
||||
panOnScroll={!isCommandPressed}
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={!isSpacePressed}
|
||||
{...props}
|
||||
>
|
||||
{backgroundStyle === "lines" && (
|
||||
<Background
|
||||
color={theme === "light" ? "rgba(0,0,0,.1)" : "rgba(255,255,255,.1)"}
|
||||
gap={[50, 50]}
|
||||
variant={BackgroundVariant.Lines}
|
||||
/>
|
||||
)}
|
||||
{backgroundStyle === "dots" && (
|
||||
<Background color={theme === "light" ? "rgba(0,0,0,.5)" : "rgba(255,255,255,.2)"} />
|
||||
)}
|
||||
{minimap && <MiniMap {...(typeof minimap === "object" ? minimap : {})} />}
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
type TDefaultNodeProps = ElementProps<"div"> & {
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export function DefaultNode({ selected, children, className, ...props }: TDefaultNodeProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative w-80 shadow-lg rounded-lg bg-background",
|
||||
selected && "outline outline-blue-500/25",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TDefaultNodeHeaderProps = ElementProps<"div"> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const Header: React.FC<TDefaultNodeHeaderProps> = ({ className, label, children, ...props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-primary/15 justify-center items-center rounded-tl-lg rounded-tr-lg py-1 px-2 drag-handle",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="font-semibold opacity-75 font-mono">{label ?? "Untitled node"}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Content: React.FC<ElementProps<"div">> = ({ children, className, ...props }) => (
|
||||
<div {...props} className={twMerge("px-2 py-1.5 pb-2 flex flex-col", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultNode.Header = Header;
|
||||
DefaultNode.Content = Content;
|
||||
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
type Position = "top" | "right" | "bottom" | "left";
|
||||
type Node = {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type LayoutProps = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
graph?: Dagre.GraphLabel;
|
||||
};
|
||||
|
||||
export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
|
||||
const dagreGraph = new Dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph(graph || {});
|
||||
/*dagreGraph.setGraph({
|
||||
rankdir: "LR",
|
||||
align: "UR",
|
||||
nodesep: NODE_SEP,
|
||||
ranksep: RANK_SEP
|
||||
});*/
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: node.width,
|
||||
height: node.height
|
||||
});
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.target, edge.source);
|
||||
});
|
||||
|
||||
Dagre.layout(dagreGraph);
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const position = dagreGraph.node(node.id);
|
||||
return { ...node, x: position.x, y: position.y };
|
||||
}),
|
||||
edges
|
||||
};
|
||||
};
|
||||
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type PanelPosition, Panel as XYPanel } from "@xyflow/react";
|
||||
import { type ComponentPropsWithoutRef, type HTMLAttributes, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton as _IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
export type PanelProps = HTMLAttributes<HTMLDivElement> & {
|
||||
position: PanelPosition;
|
||||
unstyled?: boolean;
|
||||
};
|
||||
|
||||
export function Panel({ position, className, children, unstyled, ...props }: PanelProps) {
|
||||
if (unstyled) {
|
||||
return (
|
||||
<XYPanel
|
||||
position={position}
|
||||
className={twMerge("flex flex-row p-1 gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XYPanel position={position} {...props}>
|
||||
<Wrapper className={className}>{children}</Wrapper>
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = ({ children, className, ...props }: ComponentPropsWithoutRef<"div">) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-lightest border ring-2 ring-muted/5 border-muted rounded-full items-center p-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const IconButton = ({
|
||||
Icon,
|
||||
size = "lg",
|
||||
variant = "ghost",
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
round,
|
||||
...rest
|
||||
}: ComponentPropsWithoutRef<typeof _IconButton> & { round?: boolean }) => (
|
||||
<_IconButton
|
||||
Icon={Icon}
|
||||
size={size}
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(round ? "rounded-full" : "", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
const Text = forwardRef<any, ComponentPropsWithoutRef<"span"> & { mono?: boolean }>(
|
||||
({ children, className, mono, ...props }, ref) => (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge("text-md font-medium leading-none", mono && "font-mono", className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
Panel.Wrapper = Wrapper;
|
||||
Panel.IconButton = IconButton;
|
||||
Panel.Text = Text;
|
||||
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { MiniMap, useReactFlow, useViewport } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { TbMaximize, TbMinus, TbPlus, TbSitemap } from "react-icons/tb";
|
||||
import { Panel } from "ui/components/canvas/panels/Panel";
|
||||
|
||||
export type PanelsProps = {
|
||||
children?: React.ReactNode;
|
||||
coordinates?: boolean;
|
||||
minimap?: boolean;
|
||||
zoom?: boolean;
|
||||
};
|
||||
|
||||
export function Panels({ children, ...props }: PanelsProps) {
|
||||
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 (
|
||||
<>
|
||||
{children}
|
||||
{props.coordinates && (
|
||||
<Panel position="bottom-center">
|
||||
<Panel.Text className="px-2" mono>
|
||||
{x.toFixed(2)},{y.toFixed(2)}
|
||||
</Panel.Text>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel unstyled position="bottom-right">
|
||||
{props.zoom && (
|
||||
<>
|
||||
<Panel.Wrapper className="px-1.5">
|
||||
<Panel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
|
||||
<Panel.Text className="px-2" mono onClick={handleZoomReset}>
|
||||
{percent}%
|
||||
</Panel.Text>
|
||||
<Panel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
|
||||
<Panel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
|
||||
</Panel.Wrapper>
|
||||
</>
|
||||
)}
|
||||
{props.minimap && (
|
||||
<>
|
||||
<Panel.Wrapper>
|
||||
<Panel.IconButton
|
||||
Icon={minimap ? TbSitemap : TbSitemap}
|
||||
round
|
||||
onClick={toggleMinimap}
|
||||
variant={minimap ? "default" : "ghost"}
|
||||
/>
|
||||
</Panel.Wrapper>
|
||||
{minimap && <MiniMap style={{ bottom: 50, right: -5 }} ariaLabel={null} />}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
app/src/ui/components/code/CodeEditor.tsx
Normal file
27
app/src/ui/components/code/CodeEditor.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client";
|
||||
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
|
||||
|
||||
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
|
||||
? {
|
||||
...(typeof basicSetup === "object" ? basicSetup : {}),
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false
|
||||
}
|
||||
: basicSetup;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<CodeMirror
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
22
app/src/ui/components/code/JsonEditor.tsx
Normal file
22
app/src/ui/components/code/JsonEditor.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted",
|
||||
!editable && "opacity-70",
|
||||
className
|
||||
)}
|
||||
editable={editable}
|
||||
extensions={[json()]}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
71
app/src/ui/components/code/JsonViewer.tsx
Normal file
71
app/src/ui/components/code/JsonViewer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { IconCopy } from "@tabler/icons-react";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { JsonView } from "react-json-view-lite";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
|
||||
export const JsonViewer = ({
|
||||
json,
|
||||
title,
|
||||
expand = 0,
|
||||
showSize = false,
|
||||
showCopy = false,
|
||||
className
|
||||
}: {
|
||||
json: object;
|
||||
title?: string;
|
||||
expand?: number;
|
||||
showSize?: boolean;
|
||||
showCopy?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
||||
const showContext = size || title || showCopy;
|
||||
|
||||
function onCopy() {
|
||||
navigator.clipboard?.writeText(JSON.stringify(json, null, 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||
{showContext && (
|
||||
<div className="absolute right-4 top-4 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||
{(title || size) && (
|
||||
<div className="flex flex-row">
|
||||
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
||||
</div>
|
||||
)}
|
||||
{showCopy && (
|
||||
<div>
|
||||
<IconButton Icon={TbCopy} onClick={onCopy} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<JsonView
|
||||
data={json}
|
||||
shouldExpandNode={(level) => level < expand}
|
||||
style={
|
||||
{
|
||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
||||
container: "ml-[-10px]",
|
||||
label: "text-primary/90 font-bold font-mono mr-2",
|
||||
stringValue: "text-emerald-500 font-mono select-text",
|
||||
numberValue: "text-sky-400 font-mono",
|
||||
nullValue: "text-zinc-400 font-mono",
|
||||
undefinedValue: "text-zinc-400 font-mono",
|
||||
otherValue: "text-zinc-400 font-mono",
|
||||
booleanValue: "text-orange-400 font-mono",
|
||||
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
|
||||
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
|
||||
collapseIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
|
||||
expandIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
|
||||
noQuotesForStringValues: false
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
app/src/ui/components/code/LiquidJsEditor.tsx
Normal file
123
app/src/ui/components/code/LiquidJsEditor.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { liquid } from "@codemirror/lang-liquid";
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
const filters = [
|
||||
{ label: "abs" },
|
||||
{ label: "append" },
|
||||
{ label: "array_to_sentence_string" },
|
||||
{ label: "at_least" },
|
||||
{ label: "at_most" },
|
||||
{ label: "capitalize" },
|
||||
{ label: "ceil" },
|
||||
{ label: "cgi_escape" },
|
||||
{ label: "compact" },
|
||||
{ label: "concat" },
|
||||
{ label: "date" },
|
||||
{ label: "date_to_long_string" },
|
||||
{ label: "date_to_rfc822" },
|
||||
{ label: "date_to_string" },
|
||||
{ label: "date_to_xmlschema" },
|
||||
{ label: "default" },
|
||||
{ label: "divided_by" },
|
||||
{ label: "downcase" },
|
||||
{ label: "escape" },
|
||||
{ label: "escape_once" },
|
||||
{ label: "find" },
|
||||
{ label: "find_exp" },
|
||||
{ label: "first" },
|
||||
{ label: "floor" },
|
||||
{ label: "group_by" },
|
||||
{ label: "group_by_exp" },
|
||||
{ label: "inspect" },
|
||||
{ label: "join" },
|
||||
{ label: "json" },
|
||||
{ label: "jsonify" },
|
||||
{ label: "last" },
|
||||
{ label: "lstrip" },
|
||||
{ label: "map" },
|
||||
{ label: "minus" },
|
||||
{ label: "modulo" },
|
||||
{ label: "newline_to_br" },
|
||||
{ label: "normalize_whitespace" },
|
||||
{ label: "number_of_words" },
|
||||
{ label: "plus" },
|
||||
{ label: "pop" },
|
||||
{ label: "push" },
|
||||
{ label: "prepend" },
|
||||
{ label: "raw" },
|
||||
{ label: "remove" },
|
||||
{ label: "remove_first" },
|
||||
{ label: "remove_last" },
|
||||
{ label: "replace" },
|
||||
{ label: "replace_first" },
|
||||
{ label: "replace_last" },
|
||||
{ label: "reverse" },
|
||||
{ label: "round" },
|
||||
{ label: "rstrip" },
|
||||
{ label: "shift" },
|
||||
{ label: "size" },
|
||||
{ label: "slice" },
|
||||
{ label: "slugify" },
|
||||
{ label: "sort" },
|
||||
{ label: "sort_natural" },
|
||||
{ label: "split" },
|
||||
{ label: "strip" },
|
||||
{ label: "strip_html" },
|
||||
{ label: "strip_newlines" },
|
||||
{ label: "sum" },
|
||||
{ label: "times" },
|
||||
{ label: "to_integer" },
|
||||
{ label: "truncate" },
|
||||
{ label: "truncatewords" },
|
||||
{ label: "uniq" },
|
||||
{ label: "unshift" },
|
||||
{ label: "upcase" },
|
||||
{ label: "uri_escape" },
|
||||
{ label: "url_decode" },
|
||||
{ label: "url_encode" },
|
||||
{ label: "where" },
|
||||
{ label: "where_exp" },
|
||||
{ label: "xml_escape" }
|
||||
];
|
||||
|
||||
const tags = [
|
||||
{ label: "assign" },
|
||||
{ label: "capture" },
|
||||
{ label: "case" },
|
||||
{ label: "comment" },
|
||||
{ label: "cycle" },
|
||||
{ label: "decrement" },
|
||||
{ label: "echo" },
|
||||
{ label: "else" },
|
||||
{ label: "elsif" },
|
||||
{ label: "for" },
|
||||
{ label: "if" },
|
||||
{ label: "include" },
|
||||
{ label: "increment" },
|
||||
{ label: "layout" },
|
||||
{ label: "liquid" },
|
||||
{ label: "raw" },
|
||||
{ label: "render" },
|
||||
{ label: "tablerow" },
|
||||
{ label: "unless" },
|
||||
{ label: "when" }
|
||||
];
|
||||
|
||||
export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted bg-white rounded-lg",
|
||||
!editable && "opacity-70"
|
||||
)}
|
||||
editable={editable}
|
||||
extensions={[liquid({ filters, tags })]}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
33
app/src/ui/components/display/Empty.tsx
Normal file
33
app/src/ui/components/display/Empty.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "../buttons/Button";
|
||||
|
||||
type EmptyProps = {
|
||||
Icon?: any;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: () => void;
|
||||
};
|
||||
export const Empty: React.FC<EmptyProps> = ({
|
||||
Icon = undefined,
|
||||
title = undefined,
|
||||
description = "Check back later my friend.",
|
||||
buttonText,
|
||||
buttonOnClick
|
||||
}) => (
|
||||
<div className="flex flex-col h-full w-full justify-center items-center">
|
||||
<div className="flex flex-col gap-3 items-center max-w-80">
|
||||
{Icon && <Icon size={48} className="opacity-50" stroke={1} />}
|
||||
<div className="flex flex-col gap-1">
|
||||
{title && <h3 className="text-center text-lg font-bold">{title}</h3>}
|
||||
<p className="text-center text-primary/60">{description}</p>
|
||||
</div>
|
||||
{buttonText && (
|
||||
<div className="mt-1.5">
|
||||
<Button variant="primary" onClick={buttonOnClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
31
app/src/ui/components/display/Logo.tsx
Normal file
31
app/src/ui/components/display/Logo.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useBknd } from "../../client/BkndProvider";
|
||||
|
||||
export function Logo({ scale = 0.2, fill }: { scale?: number; fill?: string }) {
|
||||
const { app } = useBknd();
|
||||
const theme = app.getAdminConfig().color_scheme;
|
||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||
|
||||
const dim = {
|
||||
width: Math.round(578 * scale),
|
||||
height: Math.round(188 * scale)
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={dim}>
|
||||
<svg
|
||||
width={dim.width}
|
||||
height={dim.height}
|
||||
viewBox="0 0 578 188"
|
||||
fill={svgFill}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M41.5 34C37.0817 34 33.5 37.5817 33.5 42V146C33.5 150.418 37.0817 154 41.5 154H158.5C162.918 154 166.5 150.418 166.5 146V42C166.5 37.5817 162.918 34 158.5 34H41.5ZM123.434 113.942C124.126 111.752 124.5 109.42 124.5 107C124.5 94.2975 114.203 84 101.5 84C99.1907 84 96.9608 84.3403 94.8579 84.9736L87.2208 65.1172C90.9181 63.4922 93.5 59.7976 93.5 55.5C93.5 49.701 88.799 45 83 45C77.201 45 72.5 49.701 72.5 55.5C72.5 61.299 77.201 66 83 66C83.4453 66 83.8841 65.9723 84.3148 65.9185L92.0483 86.0256C87.1368 88.2423 83.1434 92.1335 80.7957 96.9714L65.4253 91.1648C65.4746 90.7835 65.5 90.3947 65.5 90C65.5 85.0294 61.4706 81 56.5 81C51.5294 81 47.5 85.0294 47.5 90C47.5 94.9706 51.5294 99 56.5 99C60.0181 99 63.0648 96.9814 64.5449 94.0392L79.6655 99.7514C78.9094 102.03 78.5 104.467 78.5 107C78.5 110.387 79.2321 113.603 80.5466 116.498L69.0273 123.731C67.1012 121.449 64.2199 120 61 120C55.201 120 50.5 124.701 50.5 130.5C50.5 136.299 55.201 141 61 141C66.799 141 71.5 136.299 71.5 130.5C71.5 128.997 71.1844 127.569 70.6158 126.276L81.9667 119.149C86.0275 125.664 93.2574 130 101.5 130C110.722 130 118.677 124.572 122.343 116.737L132.747 120.899C132.585 121.573 132.5 122.276 132.5 123C132.5 127.971 136.529 132 141.5 132C146.471 132 150.5 127.971 150.5 123C150.5 118.029 146.471 114 141.5 114C138.32 114 135.525 115.649 133.925 118.139L123.434 113.942Z"
|
||||
/>
|
||||
<path d="M243.9 151.5C240.4 151.5 237 151 233.7 150C230.4 149 227.4 147.65 224.7 145.95C222 144.15 219.75 142.15 217.95 139.95C216.15 137.65 215 135.3 214.5 132.9L219.3 131.1L218.25 149.7H198.15V39H219.45V89.25L215.4 87.6C216 85.2 217.15 82.9 218.85 80.7C220.55 78.4 222.7 76.4 225.3 74.7C227.9 72.9 230.75 71.5 233.85 70.5C236.95 69.5 240.15 69 243.45 69C250.35 69 256.5 70.8 261.9 74.4C267.3 77.9 271.55 82.75 274.65 88.95C277.85 95.15 279.45 102.25 279.45 110.25C279.45 118.25 277.9 125.35 274.8 131.55C271.7 137.75 267.45 142.65 262.05 146.25C256.75 149.75 250.7 151.5 243.9 151.5ZM238.8 133.35C242.8 133.35 246.25 132.4 249.15 130.5C252.15 128.5 254.5 125.8 256.2 122.4C257.9 118.9 258.75 114.85 258.75 110.25C258.75 105.75 257.9 101.75 256.2 98.25C254.6 94.75 252.3 92.05 249.3 90.15C246.3 88.25 242.8 87.3 238.8 87.3C234.8 87.3 231.3 88.25 228.3 90.15C225.3 92.05 222.95 94.75 221.25 98.25C219.55 101.75 218.7 105.75 218.7 110.25C218.7 114.85 219.55 118.9 221.25 122.4C222.95 125.8 225.3 128.5 228.3 130.5C231.3 132.4 234.8 133.35 238.8 133.35ZM308.312 126.15L302.012 108.6L339.512 70.65H367.562L308.312 126.15ZM288.062 150V39H309.362V150H288.062ZM341.762 150L313.262 114.15L328.262 102.15L367.412 150H341.762ZM371.675 150V70.65H392.075L392.675 86.85L388.475 88.65C389.575 85.05 391.525 81.8 394.325 78.9C397.225 75.9 400.675 73.5 404.675 71.7C408.675 69.9 412.875 69 417.275 69C423.275 69 428.275 70.2 432.275 72.6C436.375 75 439.425 78.65 441.425 83.55C443.525 88.35 444.575 94.3 444.575 101.4V150H423.275V103.05C423.275 99.45 422.775 96.45 421.775 94.05C420.775 91.65 419.225 89.9 417.125 88.8C415.125 87.6 412.625 87.1 409.625 87.3C407.225 87.3 404.975 87.7 402.875 88.5C400.875 89.2 399.125 90.25 397.625 91.65C396.225 93.05 395.075 94.65 394.175 96.45C393.375 98.25 392.975 100.2 392.975 102.3V150H382.475C380.175 150 378.125 150 376.325 150C374.525 150 372.975 150 371.675 150ZM488.536 151.5C481.636 151.5 475.436 149.75 469.936 146.25C464.436 142.65 460.086 137.8 456.886 131.7C453.786 125.5 452.236 118.35 452.236 110.25C452.236 102.35 453.786 95.3 456.886 89.1C460.086 82.9 464.386 78 469.786 74.4C475.286 70.8 481.536 69 488.536 69C492.236 69 495.786 69.6 499.186 70.8C502.686 71.9 505.786 73.45 508.486 75.45C511.286 77.45 513.536 79.7 515.236 82.2C516.936 84.6 517.886 87.15 518.086 89.85L512.686 90.75V39H533.986V150H513.886L512.986 131.7L517.186 132.15C516.986 134.65 516.086 137.05 514.486 139.35C512.886 141.65 510.736 143.75 508.036 145.65C505.436 147.45 502.436 148.9 499.036 150C495.736 151 492.236 151.5 488.536 151.5ZM493.336 133.8C497.336 133.8 500.836 132.8 503.836 130.8C506.836 128.8 509.186 126.05 510.886 122.55C512.586 119.05 513.436 114.95 513.436 110.25C513.436 105.65 512.586 101.6 510.886 98.1C509.186 94.5 506.836 91.75 503.836 89.85C500.836 87.85 497.336 86.85 493.336 86.85C489.336 86.85 485.836 87.85 482.836 89.85C479.936 91.75 477.636 94.5 475.936 98.1C474.336 101.6 473.536 105.65 473.536 110.25C473.536 114.95 474.336 119.05 475.936 122.55C477.636 126.05 479.936 128.8 482.836 130.8C485.836 132.8 489.336 133.8 493.336 133.8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FloatingIndicator, Input, UnstyledButton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type FloatingSelectProps = {
|
||||
data: string[];
|
||||
description?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function FloatingSelect({ data, label, description }: FloatingSelectProps) {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const setControlRef = (index: number) => (node: HTMLButtonElement) => {
|
||||
controlsRefs[index] = node;
|
||||
setControlsRefs(controlsRefs);
|
||||
};
|
||||
|
||||
const controls = data.map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
className={twMerge(
|
||||
"transition-colors duration-100 px-2.5 py-2 leading-none rounded-lg text-md",
|
||||
active === index && "text-white"
|
||||
)}
|
||||
ref={setControlRef(index)}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
<span className="relative z-[1]">{item}</span>
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label>{label}</Input.Label>
|
||||
{description && <Input.Description>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-fit bg-primary/5 px-1.5 py-1 rounded-lg" ref={setRootRef}>
|
||||
{controls}
|
||||
|
||||
<FloatingIndicator
|
||||
target={controlsRefs[active]}
|
||||
parent={rootRef}
|
||||
className="bg-primary rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{/*<Input.Error>Input error</Input.Error>*/}
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
176
app/src/ui/components/form/Formy.tsx
Normal file
176
app/src/ui/components/form/Formy.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
error,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
case "date":
|
||||
return DateInput;
|
||||
case "boolean":
|
||||
return BooleanInput;
|
||||
case "textarea":
|
||||
return Textarea;
|
||||
default:
|
||||
return Input;
|
||||
}
|
||||
};
|
||||
|
||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
...props
|
||||
}) => {
|
||||
const desc = field.getDescription();
|
||||
return (
|
||||
<Label {...props} title={desc} className="flex flex-row gap-2 items-center">
|
||||
{field.getLabel()}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>((props, ref) => {
|
||||
const disabledOrReadonly = props.disabled || props.readOnly;
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||
!disabledOrReadonly &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const DateInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
const browser = getBrowser();
|
||||
useImperativeHandle(ref, () => innerRef.current!);
|
||||
|
||||
const handleClick = useEvent(() => {
|
||||
if (innerRef?.current) {
|
||||
innerRef.current.focus();
|
||||
if (["Safari"].includes(browser)) {
|
||||
innerRef.current.click();
|
||||
} else {
|
||||
innerRef.current.showPicker();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="absolute h-full right-3 top-0 bottom-0 flex items-center">
|
||||
<IconButton Icon={TbCalendar} onClick={handleClick} />
|
||||
</div>
|
||||
<Input
|
||||
{...props}
|
||||
type={props.type ?? "date"}
|
||||
ref={innerRef}
|
||||
className="w-full appearance-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const [checked, setChecked] = useState(Boolean(props.value));
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(Boolean(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
function handleCheck(e) {
|
||||
setChecked(e.target.checked);
|
||||
props.onChange?.(e.target.checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
id={props.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/*return (
|
||||
<div className="h-11 flex items-center">
|
||||
<input
|
||||
{...props}
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
);*/
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
||||
(props, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
"border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
16
app/src/ui/components/form/SearchInput.tsx
Normal file
16
app/src/ui/components/form/SearchInput.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { TbSearch } from "react-icons/tb";
|
||||
|
||||
export const SearchInput = (props: ElementProps<"input">) => (
|
||||
<div className="w-full relative shadow-sm">
|
||||
<div className="absolute h-full flex items-center px-3 mt-[0.5px] text-zinc-500">
|
||||
<TbSearch size={18} />
|
||||
</div>
|
||||
<input
|
||||
className="bg-transparent border-muted border rounded-md py-2 pl-10 pr-3 w-full outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all duration-200 ease-in-out"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Input,
|
||||
SegmentedControl as MantineSegmentedControl,
|
||||
type SegmentedControlProps as MantineSegmentedControlProps
|
||||
} from "@mantine/core";
|
||||
|
||||
type SegmentedControlProps = MantineSegmentedControlProps & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function SegmentedControl({ label, description, size, ...props }: SegmentedControlProps) {
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<MantineSegmentedControl {...props} size={size} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
NumberInput as $NumberInput,
|
||||
type NumberInputProps as $NumberInputProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineNumberInputProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$NumberInputProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineNumberInput<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineNumberInputProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$NumberInput
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Radio as $Radio,
|
||||
RadioGroup as $RadioGroup,
|
||||
type RadioGroupProps as $RadioGroupProps,
|
||||
type RadioProps as $RadioProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type RadioProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineRadio<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Radio
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type RadioGroupProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioGroupProps, "value" | "defaultValue">;
|
||||
|
||||
function RadioGroup<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioGroupProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$RadioGroup
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MantineRadio.Group = RadioGroup;
|
||||
MantineRadio.Item = $Radio;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
SegmentedControl as $SegmentedControl,
|
||||
type SegmentedControlProps as $SegmentedControlProps,
|
||||
Input
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSegmentedControlProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SegmentedControlProps, "values" | "defaultValues"> & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function MantineSegmentedControl<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
label,
|
||||
size,
|
||||
description,
|
||||
error,
|
||||
...props
|
||||
}: MantineSegmentedControlProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<$SegmentedControl
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
size={size}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Select, type SelectProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSelectProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<SelectProps, "value" | "defaultValue">;
|
||||
|
||||
// @todo: change is not triggered correctly
|
||||
export function MantineSelect<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineSelectProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={async (e) => {
|
||||
//console.log("change1", name, field.name, e);
|
||||
await fieldOnChange({
|
||||
...new Event("change", { bubbles: true, cancelable: true }),
|
||||
target: {
|
||||
value: e,
|
||||
name: field.name
|
||||
}
|
||||
});
|
||||
// @ts-ignore
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Switch as $Switch, type SwitchProps as $SwitchProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type SwitchProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SwitchProps, "value" | "checked" | "defaultValue">;
|
||||
|
||||
export function MantineSwitch<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: SwitchProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Switch
|
||||
value={value}
|
||||
checked={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Schema } from "@cfworker/json-schema";
|
||||
import Form from "@rjsf/core";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
|
||||
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
|
||||
import { fields as Fields } from "./fields";
|
||||
import { templates as Templates } from "./templates";
|
||||
import { widgets as Widgets } from "./widgets";
|
||||
import "./styles.css";
|
||||
import { filterKeys } from "core/utils";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
|
||||
|
||||
const validator = new RJSFTypeboxValidator();
|
||||
|
||||
// @todo: don't import FormProps, instead, copy it here instead of "any"
|
||||
export type JsonSchemaFormProps = any & {
|
||||
schema: RJSFSchema | Schema;
|
||||
uiSchema?: any;
|
||||
direction?: "horizontal" | "vertical";
|
||||
onChange?: (value: any) => void;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormRef = {
|
||||
formData: () => any;
|
||||
validateForm: () => boolean;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
direction = "vertical",
|
||||
schema,
|
||||
onChange,
|
||||
uiSchema,
|
||||
templates,
|
||||
fields,
|
||||
widgets,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const formRef = useRef<Form<any, RJSFSchema, any>>(null);
|
||||
const id = useId();
|
||||
const [value, setValue] = useState<any>(props.formData);
|
||||
|
||||
const onSubmit = ({ formData }: any, e) => {
|
||||
e.preventDefault();
|
||||
console.log("Data submitted: ", formData);
|
||||
props.onSubmit?.(formData);
|
||||
return false;
|
||||
};
|
||||
const handleChange = ({ formData }: any, e) => {
|
||||
const clean = JSON.parse(JSON.stringify(formData));
|
||||
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
|
||||
onChange?.(clean);
|
||||
setValue(clean);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
formData: () => value,
|
||||
validateForm: () => formRef.current!.validateForm(),
|
||||
cancel: () => formRef.current!.reset()
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const _uiSchema: UiSchema = {
|
||||
...uiSchema,
|
||||
"ui:globalOptions": {
|
||||
...uiSchema?.["ui:globalOptions"],
|
||||
enableMarkdownInDescription: true
|
||||
},
|
||||
"ui:submitButtonOptions": {
|
||||
norender: true
|
||||
}
|
||||
};
|
||||
const _fields: any = {
|
||||
...Fields,
|
||||
...fields
|
||||
};
|
||||
const _templates: any = {
|
||||
...Templates,
|
||||
...templates
|
||||
};
|
||||
const _widgets: any = {
|
||||
...Widgets,
|
||||
...widgets
|
||||
};
|
||||
//console.log("schema", schema, removeTitleFromSchema(schema));
|
||||
|
||||
return (
|
||||
<Form
|
||||
tagName="div"
|
||||
idSeparator="--"
|
||||
idPrefix={id}
|
||||
{...props}
|
||||
ref={formRef}
|
||||
className={["json-form", direction, className].join(" ")}
|
||||
showErrorList={false}
|
||||
schema={schema as RJSFSchema}
|
||||
fields={_fields}
|
||||
templates={_templates}
|
||||
widgets={_widgets}
|
||||
uiSchema={_uiSchema}
|
||||
onChange={handleChange}
|
||||
onSubmit={onSubmit}
|
||||
validator={validator as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
function removeTitleFromSchema(schema: any): any {
|
||||
// Create a deep copy of the schema using lodash
|
||||
const newSchema = cloneDeep(schema);
|
||||
|
||||
function removeTitle(schema: any): void {
|
||||
if (typeof schema !== "object" || schema === null) return;
|
||||
|
||||
// Remove title if present
|
||||
// biome-ignore lint/performance/noDelete: <explanation>
|
||||
delete schema.title;
|
||||
|
||||
// Check nested schemas in anyOf, allOf, and oneOf
|
||||
const nestedKeywords = ["anyOf", "allOf", "oneOf"];
|
||||
nestedKeywords.forEach((keyword) => {
|
||||
if (Array.isArray(schema[keyword])) {
|
||||
schema[keyword].forEach((nestedSchema: any) => {
|
||||
removeTitle(nestedSchema);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively remove title from properties
|
||||
if (schema.properties && typeof schema.properties === "object") {
|
||||
Object.values(schema.properties).forEach((propertySchema: any) => {
|
||||
removeTitle(propertySchema);
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively remove title from items
|
||||
if (schema.items) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
schema.items.forEach((itemSchema: any) => {
|
||||
removeTitle(itemSchema);
|
||||
});
|
||||
} else {
|
||||
removeTitle(schema.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTitle(newSchema);
|
||||
return newSchema;
|
||||
}
|
||||
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { type OutputUnit, Validator } from "@cfworker/json-schema";
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorSchema,
|
||||
ErrorTransformer,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
|
||||
function removeUndefinedKeys(obj: any): any {
|
||||
if (!obj) return obj;
|
||||
|
||||
if (typeof obj === "object") {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] === undefined) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === "object") {
|
||||
removeUndefinedKeys(obj[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function onlyKeepMostSpecific(errors: OutputUnit[]) {
|
||||
const mostSpecific = errors.filter((error) => {
|
||||
return !errors.some((other) => {
|
||||
return error !== other && other.instanceLocation.startsWith(error.instanceLocation);
|
||||
});
|
||||
});
|
||||
return mostSpecific;
|
||||
}
|
||||
|
||||
const debug = true;
|
||||
const validate = true;
|
||||
|
||||
export class JsonSchemaValidator<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation<Result extends OutputUnit = OutputUnit>(schema: S, formData?: T) {
|
||||
if (!validate) return { errors: [], validationError: null };
|
||||
|
||||
debug && console.log("JsonSchemaValidator.rawValidation", schema, formData);
|
||||
const validator = new Validator(schema as any);
|
||||
const validation = validator.validate(removeUndefinedKeys(formData));
|
||||
const specificErrors = onlyKeepMostSpecific(validation.errors);
|
||||
|
||||
return { errors: specificErrors, validationError: null as any };
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
if (!validate) return { errors: [], errorSchema: {} as any };
|
||||
|
||||
debug &&
|
||||
console.log(
|
||||
"JsonSchemaValidator.validateFormData",
|
||||
formData,
|
||||
schema,
|
||||
customValidate,
|
||||
transformErrors,
|
||||
uiSchema
|
||||
);
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
debug && console.log("errors", { errors });
|
||||
|
||||
const transformedErrors = errors
|
||||
//.filter((error) => error.keyword !== "properties")
|
||||
.map((error) => {
|
||||
const schemaLocation = error.keywordLocation.replace(/^#\/?/, "").split("/").join(".");
|
||||
const propertyError = get(schema, schemaLocation);
|
||||
const errorText = `${error.error.replace(/\.$/, "")}${propertyError ? ` "${propertyError}"` : ""}`;
|
||||
//console.log(error, schemaLocation, get(schema, schemaLocation));
|
||||
return {
|
||||
name: error.keyword,
|
||||
message: errorText,
|
||||
property: "." + error.instanceLocation.replace(/^#\/?/, "").split("/").join("."),
|
||||
schemaPath: error.keywordLocation,
|
||||
stack: error.error
|
||||
};
|
||||
});
|
||||
debug && console.log("transformed", transformedErrors);
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
toErrorList(errorSchema?: ErrorSchema<T>, fieldPath?: string[]): RJSFValidationError[] {
|
||||
debug && console.log("JsonSchemaValidator.toErrorList", errorSchema, fieldPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
if (!validate) return true;
|
||||
debug && console.log("JsonSchemaValidator.isValid", schema, formData, rootSchema);
|
||||
return this.rawValidation(schema, formData).errors.length === 0;
|
||||
}
|
||||
}
|
||||
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { JsonEditor } from "../../../code/JsonEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function JsonField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { LiquidJsEditor } from "../../../code/LiquidJsEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function LiquidJsField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
function handleChange(data) {
|
||||
onChange(data);
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<LiquidJsEditor value={formData} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
ANY_OF_KEY,
|
||||
ERRORS_KEY,
|
||||
type FieldProps,
|
||||
type FormContextType,
|
||||
ONE_OF_KEY,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type UiSchema,
|
||||
deepEquals,
|
||||
getDiscriminatorFieldFromSchema,
|
||||
getUiOptions,
|
||||
getWidget,
|
||||
mergeSchemas
|
||||
} from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
import isEmpty from "lodash-es/isEmpty";
|
||||
import omit from "lodash-es/omit";
|
||||
import { Component } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
/** Type used for the state of the `AnyOfField` component */
|
||||
type AnyOfFieldState<S extends StrictRJSFSchema = RJSFSchema> = {
|
||||
/** The currently selected option */
|
||||
selectedOption: number;
|
||||
/** The option schemas after retrieving all $refs */
|
||||
retrievedOptions: S[];
|
||||
};
|
||||
|
||||
/** The `AnyOfField` component is used to render a field in the schema that is an `anyOf`, `allOf` or `oneOf`. It tracks
|
||||
* the currently selected option and cleans up any irrelevant data in `formData`.
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
class MultiSchemaField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> extends Component<FieldProps<T, S, F>, AnyOfFieldState<S>> {
|
||||
/** Constructs an `AnyOfField` with the given `props` to initialize the initially selected option in state
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
constructor(props: FieldProps<T, S, F>) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
formData,
|
||||
options,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// cache the retrieved options in state in case they have $refs to save doing it later
|
||||
//console.log("multi schema", { formData, options, props });
|
||||
const retrievedOptions = options.map((opt: S) => schemaUtils.retrieveSchema(opt, formData));
|
||||
|
||||
this.state = {
|
||||
retrievedOptions,
|
||||
selectedOption: this.getMatchingOption(0, formData, retrievedOptions)
|
||||
};
|
||||
}
|
||||
|
||||
/** React lifecycle method that is called when the props and/or state for this component is updated. It recomputes the
|
||||
* currently selected option based on the overall `formData`
|
||||
*
|
||||
* @param prevProps - The previous `FieldProps` for this template
|
||||
* @param prevState - The previous `AnyOfFieldState` for this template
|
||||
*/
|
||||
override componentDidUpdate(
|
||||
prevProps: Readonly<FieldProps<T, S, F>>,
|
||||
prevState: Readonly<AnyOfFieldState>
|
||||
) {
|
||||
const { formData, options, idSchema } = this.props;
|
||||
const { selectedOption } = this.state;
|
||||
let newState = this.state;
|
||||
if (!deepEquals(prevProps.options, options)) {
|
||||
const {
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// re-cache the retrieved options in state in case they have $refs to save doing it later
|
||||
const retrievedOptions = options.map((opt: S) =>
|
||||
schemaUtils.retrieveSchema(opt, formData)
|
||||
);
|
||||
newState = { selectedOption, retrievedOptions };
|
||||
}
|
||||
if (!deepEquals(formData, prevProps.formData) && idSchema.$id === prevProps.idSchema.$id) {
|
||||
const { retrievedOptions } = newState;
|
||||
const matchingOption = this.getMatchingOption(selectedOption, formData, retrievedOptions);
|
||||
|
||||
if (prevState && matchingOption !== selectedOption) {
|
||||
newState = { selectedOption: matchingOption, retrievedOptions };
|
||||
}
|
||||
}
|
||||
if (newState !== this.state) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determines the best matching option for the given `formData` and `options`.
|
||||
*
|
||||
* @param formData - The new formData
|
||||
* @param options - The list of options to choose from
|
||||
* @return - The index of the `option` that best matches the `formData`
|
||||
*/
|
||||
getMatchingOption(selectedOption: number, formData: T | undefined, options: S[]) {
|
||||
const {
|
||||
schema,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
|
||||
const discriminator = getDiscriminatorFieldFromSchema<S>(schema);
|
||||
const option = schemaUtils.getClosestMatchingOption(
|
||||
formData,
|
||||
options,
|
||||
selectedOption,
|
||||
discriminator
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
/** Callback handler to remember what the currently selected option is. In addition to that the `formData` is updated
|
||||
* to remove properties that are not part of the newly selected option schema, and then the updated data is passed to
|
||||
* the `onChange` handler.
|
||||
*
|
||||
* @param option - The new option value being selected
|
||||
*/
|
||||
onOptionChange = (option?: string) => {
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const { formData, onChange, registry } = this.props;
|
||||
console.log("onOptionChange", { state: { selectedOption, retrievedOptions }, option });
|
||||
|
||||
const { schemaUtils } = registry;
|
||||
const intOption = option !== undefined ? Number.parseInt(option, 10) : -1;
|
||||
if (intOption === selectedOption) {
|
||||
return;
|
||||
}
|
||||
const newOption = intOption >= 0 ? retrievedOptions[intOption] : undefined;
|
||||
const oldOption = selectedOption >= 0 ? retrievedOptions[selectedOption] : undefined;
|
||||
|
||||
let newFormData = schemaUtils.sanitizeDataForNewSchema(newOption, oldOption, formData);
|
||||
if (newFormData && newOption) {
|
||||
// Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren"
|
||||
// so that only the root objects themselves are created without adding undefined children properties
|
||||
newFormData = schemaUtils.getDefaultFormState(
|
||||
newOption,
|
||||
newFormData,
|
||||
"excludeObjectChildren"
|
||||
) as T;
|
||||
}
|
||||
onChange(newFormData, undefined, this.getFieldId());
|
||||
|
||||
this.setState({ selectedOption: intOption });
|
||||
};
|
||||
|
||||
getFieldId() {
|
||||
const { idSchema, schema } = this.props;
|
||||
return `${idSchema.$id}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`;
|
||||
}
|
||||
|
||||
/** Renders the `AnyOfField` selector along with a `SchemaField` for the value of the `formData`
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
name,
|
||||
disabled = false,
|
||||
errorSchema = {},
|
||||
formContext,
|
||||
onBlur,
|
||||
onFocus,
|
||||
registry,
|
||||
schema,
|
||||
uiSchema,
|
||||
readonly
|
||||
} = this.props;
|
||||
|
||||
const { widgets, fields, translateString, globalUiOptions, schemaUtils } = registry;
|
||||
const { SchemaField: _SchemaField } = fields;
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const {
|
||||
widget = "select",
|
||||
placeholder,
|
||||
autofocus,
|
||||
autocomplete,
|
||||
title = schema.title,
|
||||
flexDirection,
|
||||
wrap,
|
||||
...uiOptions
|
||||
} = getUiOptions<T, S, F>(uiSchema, globalUiOptions);
|
||||
/* console.log("multi schema", {
|
||||
name,
|
||||
schema,
|
||||
uiSchema,
|
||||
uiOptions,
|
||||
globalUiOptions,
|
||||
disabled,
|
||||
flexDirection,
|
||||
props: this.props
|
||||
}); */
|
||||
const Widget = getWidget<T, S, F>({ type: "number" }, widget, widgets);
|
||||
const rawErrors = get(errorSchema, ERRORS_KEY, []);
|
||||
const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]);
|
||||
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
|
||||
|
||||
const option = selectedOption >= 0 ? retrievedOptions[selectedOption] || null : null;
|
||||
let optionSchema: S | undefined | null;
|
||||
|
||||
if (option) {
|
||||
// merge top level required field
|
||||
const { required } = schema;
|
||||
// Merge in all the non-oneOf/anyOf properties and also skip the special ADDITIONAL_PROPERTY_FLAG property
|
||||
optionSchema = required ? (mergeSchemas({ required }, option) as S) : option;
|
||||
}
|
||||
|
||||
// First we will check to see if there is an anyOf/oneOf override for the UI schema
|
||||
let optionsUiSchema: UiSchema<T, S, F>[] = [];
|
||||
if (ONE_OF_KEY in schema && uiSchema && ONE_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ONE_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ONE_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.oneOf is not an array for "${title || name}"`);
|
||||
}
|
||||
} else if (ANY_OF_KEY in schema && uiSchema && ANY_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ANY_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ANY_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.anyOf is not an array for "${title || name}"`);
|
||||
}
|
||||
}
|
||||
// Then we pick the one that matches the selected option index, if one exists otherwise default to the main uiSchema
|
||||
let optionUiSchema = uiSchema;
|
||||
if (selectedOption >= 0 && optionsUiSchema.length > selectedOption) {
|
||||
optionUiSchema = optionsUiSchema[selectedOption];
|
||||
}
|
||||
|
||||
const translateEnum: TranslatableString = title
|
||||
? TranslatableString.TitleOptionPrefix
|
||||
: TranslatableString.OptionPrefix;
|
||||
const translateParams = title ? [title] : [];
|
||||
const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => {
|
||||
// Also see if there is an override title in the uiSchema for each option, otherwise use the title from the option
|
||||
const { title: uiTitle = opt.title } = getUiOptions<T, S, F>(optionsUiSchema[index]);
|
||||
return {
|
||||
label:
|
||||
uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))),
|
||||
value: index
|
||||
};
|
||||
});
|
||||
|
||||
//console.log("sub component", { optionSchema, props: this.props, optionUiSchema });
|
||||
const SubComponent = optionSchema && (
|
||||
// @ts-ignore
|
||||
<_SchemaField
|
||||
{...this.props}
|
||||
schema={optionSchema}
|
||||
uiSchema={{
|
||||
...optionUiSchema,
|
||||
"ui:options": {
|
||||
...optionUiSchema?.["ui:options"],
|
||||
hideLabel: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"panel multischema flex",
|
||||
flexDirection === "row" ? "flex-row gap-3" : "flex-col gap-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center panel-select">
|
||||
<Label
|
||||
label={this.props.name}
|
||||
required={this.props.required}
|
||||
id={this.getFieldId()}
|
||||
/>
|
||||
<Widget
|
||||
id={this.getFieldId()}
|
||||
name={`${name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
|
||||
schema={{ type: "number", default: 0 } as S}
|
||||
onChange={this.onOptionChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
disabled={disabled || isEmpty(enumOptions) || readonly}
|
||||
multiple={false}
|
||||
rawErrors={rawErrors}
|
||||
errorSchema={fieldErrorSchema}
|
||||
value={selectedOption >= 0 ? selectedOption : undefined}
|
||||
options={{ enumOptions, ...uiOptions }}
|
||||
registry={registry}
|
||||
formContext={formContext}
|
||||
placeholder={placeholder}
|
||||
autocomplete={autocomplete}
|
||||
autofocus={autofocus}
|
||||
label={""}
|
||||
hideLabel={!displayLabel}
|
||||
/>
|
||||
</div>
|
||||
{wrap ? <fieldset>{SubComponent}</fieldset> : SubComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MultiSchemaField;
|
||||
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import JsonField from "./JsonField";
|
||||
import LiquidJsField from "./LiquidJsField";
|
||||
import MultiSchemaField from "./MultiSchemaField";
|
||||
|
||||
export const fields = {
|
||||
AnyOfField: MultiSchemaField,
|
||||
OneOfField: MultiSchemaField,
|
||||
JsonField,
|
||||
LiquidJsField
|
||||
};
|
||||
264
app/src/ui/components/form/json-schema/styles.css
Normal file
264
app/src/ui/components/form/json-schema/styles.css
Normal file
@@ -0,0 +1,264 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.json-form {
|
||||
@apply flex flex-col flex-grow;
|
||||
|
||||
/* dirty fix preventing the first fieldset to wrap */
|
||||
&.mute-root {
|
||||
& > div > div > div > fieldset:first-child {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.fieldset-alternative) {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5 border border-solid border-muted p-3 rounded;
|
||||
|
||||
.title-field {
|
||||
@apply bg-primary/10 px-3 text-sm font-medium py-1 rounded-full;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* alternative */
|
||||
&.fieldset-alternative {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5;
|
||||
&:has(> legend) {
|
||||
@apply mt-3 border-l-4 border-solid border-muted/50 p-3 pb-0 pt-0;
|
||||
}
|
||||
|
||||
.title-field {
|
||||
@apply bg-muted/50 text-sm font-medium py-1 table ml-[-14px] pl-4 pr-3 mb-3 mt-3;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
@apply mt-3;
|
||||
|
||||
fieldset {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-required-mark {
|
||||
.control-label span.required {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@apply flex flex-col gap-1;
|
||||
&:not(.field) {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
/* hide empty description if markdown is enabled */
|
||||
.field-description:has(> span:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-label span.required {
|
||||
@apply ml-1 opacity-50;
|
||||
}
|
||||
|
||||
&.field.has-error {
|
||||
@apply text-red-500;
|
||||
|
||||
.control-label {
|
||||
@apply font-bold;
|
||||
}
|
||||
.error-detail:not(:only-child) {
|
||||
@apply font-bold list-disc pl-6;
|
||||
}
|
||||
.error-detail:only-child {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-description {
|
||||
@apply text-primary/70 text-sm;
|
||||
}
|
||||
|
||||
/* input but not radio */
|
||||
input:not([type="radio"]):not([type="checkbox"]) {
|
||||
@apply flex bg-muted/40 h-11 rounded-md outline-none;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
|
||||
&:not([disabled]):not([readonly]) {
|
||||
@apply focus:outline-none focus:ring-2 focus:bg-muted focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
}
|
||||
&[disabled], &[readonly] {
|
||||
@apply bg-muted/50 text-primary/50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
@apply flex bg-muted/40 focus:bg-muted rounded-md outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
}
|
||||
.checkbox {
|
||||
label, label > span {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
@apply bg-muted/40 focus:bg-muted rounded-md py-2.5 pr-4 pl-2.5 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
@apply disabled:bg-muted/70 disabled:text-primary/70;
|
||||
@apply w-full border-r-8 border-r-transparent;
|
||||
|
||||
&:not([multiple]) {
|
||||
@apply h-11;
|
||||
}
|
||||
|
||||
&[multiple] {
|
||||
option {
|
||||
@apply py-1.5 px-2.5 bg-transparent;
|
||||
&:checked {
|
||||
@apply bg-primary/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply w-5 h-5 bg-amber-500;
|
||||
}
|
||||
|
||||
.field-radio-group {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
|
||||
&.noborder-first-fieldset {
|
||||
fieldset#root {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
.form-group {
|
||||
@apply flex-row gap-2;
|
||||
}
|
||||
.form-control, .panel {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
@apply w-32 flex h-11 items-center;
|
||||
}
|
||||
input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
fieldset#root {
|
||||
@apply gap-6;
|
||||
}
|
||||
|
||||
fieldset.object-field {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.additional-children {
|
||||
.checkbox {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-multi-labels {
|
||||
.control-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
.form-control {
|
||||
@apply flex-shrink;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
/*@apply flex flex-col gap-2;*/
|
||||
|
||||
/*.control-label { display: none; }*/
|
||||
|
||||
& > .field-radio-group {
|
||||
@apply flex flex-row gap-3;
|
||||
|
||||
.radio, .radio-inline {
|
||||
@apply text-sm border-b border-b-transparent;
|
||||
@apply font-mono text-primary/70;
|
||||
|
||||
input {
|
||||
@apply appearance-none;
|
||||
}
|
||||
&.checked {
|
||||
@apply border-b-primary/70 text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* :not(.panel-select) .control-label {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
.panel-select select {
|
||||
@apply py-1 pr-1 pl-1.5 text-sm;
|
||||
@apply h-auto w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.legacy {
|
||||
/* first fieldset */
|
||||
& > .form-group.field-object>div>fieldset {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
.col-xs-5 {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
.form-additional {
|
||||
fieldset {
|
||||
/* padding: 0;
|
||||
border: none; */
|
||||
|
||||
/* legend {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
&.additional-start {
|
||||
> label {
|
||||
display: none;
|
||||
}
|
||||
/* > label + div > fieldset:first-child {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
}
|
||||
.field-object + .field-object {
|
||||
@apply mt-3 pt-4 border-t border-muted;
|
||||
}
|
||||
.panel>.field-object>label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ArrayFieldTemplateItemType,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { type CSSProperties, Children, cloneElement, isValidElement } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** The `ArrayFieldItemTemplate` component is the template used to render an items of an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldItemTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any,
|
||||
>(props: ArrayFieldTemplateItemType<T, S, F>) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
hasToolbar,
|
||||
hasMoveDown,
|
||||
hasMoveUp,
|
||||
hasRemove,
|
||||
hasCopy,
|
||||
index,
|
||||
onCopyIndexClick,
|
||||
onDropIndexClick,
|
||||
onReorderClick,
|
||||
readonly,
|
||||
registry,
|
||||
uiSchema,
|
||||
} = props;
|
||||
const { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } =
|
||||
registry.templates.ButtonTemplates;
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex flex-row w-full overflow-hidden", className)}>
|
||||
{hasToolbar && (
|
||||
<div className="flex flex-col gap-1 p-1 mr-2">
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveUpButton
|
||||
disabled={disabled || readonly || !hasMoveUp}
|
||||
onClick={onReorderClick(index, index - 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveDownButton
|
||||
disabled={disabled || readonly || !hasMoveDown}
|
||||
onClick={onReorderClick(index, index + 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasCopy && (
|
||||
<CopyButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onCopyIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasRemove && (
|
||||
<RemoveButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
type ArrayFieldTemplateItemType,
|
||||
type ArrayFieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { cloneElement } from "react";
|
||||
|
||||
/** The `ArrayFieldTemplate` component is the template used to render all items in an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ArrayFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
canAdd,
|
||||
className,
|
||||
disabled,
|
||||
idSchema,
|
||||
uiSchema,
|
||||
items,
|
||||
onAddClick,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title
|
||||
} = props;
|
||||
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||
const ArrayFieldDescriptionTemplate = getTemplate<"ArrayFieldDescriptionTemplate", T, S, F>(
|
||||
"ArrayFieldDescriptionTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldItemTemplate = getTemplate<"ArrayFieldItemTemplate", T, S, F>(
|
||||
"ArrayFieldItemTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldTitleTemplate = getTemplate<"ArrayFieldTitleTemplate", T, S, F>(
|
||||
"ArrayFieldTitleTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
return (
|
||||
<fieldset className={className} id={idSchema.$id}>
|
||||
<ArrayFieldTitleTemplate
|
||||
idSchema={idSchema}
|
||||
title={uiOptions.title || title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<ArrayFieldDescriptionTemplate
|
||||
idSchema={idSchema}
|
||||
description={uiOptions.description || schema.description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
{items && items.length > 0 && (
|
||||
<div className="flex flex-col gap-3 array-items">
|
||||
{items.map(
|
||||
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
|
||||
const newChildren = cloneElement(children, {
|
||||
...children.props,
|
||||
name: undefined,
|
||||
title: undefined
|
||||
});
|
||||
|
||||
return (
|
||||
<ArrayFieldItemTemplate key={key} {...itemProps} children={newChildren} />
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAdd && (
|
||||
<AddButton
|
||||
className="array-item-add"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type BaseInputTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
ariaDescribedByIds,
|
||||
examplesId,
|
||||
getInputProps
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
import { Label } from "./FieldTemplate";
|
||||
|
||||
/** The `BaseInputTemplate` is the template to use to render the basic `<input>` component for the `core` theme.
|
||||
* It is used as the template for rendering many of the <input> based widgets that differ by `type` and callbacks only.
|
||||
* It can be customized/overridden for other themes or individual implementations as needed.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this template
|
||||
*/
|
||||
export default function BaseInputTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: BaseInputTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
name, // remove this from ...rest
|
||||
value,
|
||||
readonly,
|
||||
disabled,
|
||||
autofocus,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
onChangeOverride,
|
||||
options,
|
||||
schema,
|
||||
uiSchema,
|
||||
formContext,
|
||||
registry,
|
||||
rawErrors,
|
||||
type,
|
||||
hideLabel, // remove this from ...rest
|
||||
hideError, // remove this from ...rest
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
// Note: since React 15.2.0 we can't forward unknown element attributes, so we
|
||||
// exclude the "options" and "schema" ones here.
|
||||
if (!id) {
|
||||
console.log("No id for", props);
|
||||
throw new Error(`no id for props ${JSON.stringify(props)}`);
|
||||
}
|
||||
const inputProps = {
|
||||
...rest,
|
||||
...getInputProps<T, S, F>(schema, type, options)
|
||||
};
|
||||
|
||||
let inputValue;
|
||||
if (inputProps.type === "number" || inputProps.type === "integer") {
|
||||
inputValue = value || value === 0 ? value : "";
|
||||
} else {
|
||||
inputValue = value == null ? "" : value;
|
||||
}
|
||||
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(value === "" ? options.emptyValue : value),
|
||||
[onChange, options]
|
||||
);
|
||||
const _onBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onBlur(id, target && target.value),
|
||||
[onBlur, id]
|
||||
);
|
||||
const _onFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onFocus(id, target && target.value),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
const shouldHideLabel =
|
||||
!props.label ||
|
||||
// @ts-ignore
|
||||
uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!shouldHideLabel && <Label label={props.label} required={props.required} id={id} />}
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
className="form-control"
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
autoFocus={autofocus}
|
||||
value={inputValue}
|
||||
{...inputProps}
|
||||
placeholder={props.label}
|
||||
list={schema.examples ? examplesId<T>(id) : undefined}
|
||||
onChange={onChangeOverride || _onChange}
|
||||
onBlur={_onBlur}
|
||||
onFocus={_onFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id, !!schema.examples)}
|
||||
/>
|
||||
{Array.isArray(schema.examples) && (
|
||||
<datalist key={`datalist_${id}`} id={examplesId<T>(id)}>
|
||||
{(schema.examples as string[])
|
||||
.concat(
|
||||
schema.default && !schema.examples.includes(schema.default)
|
||||
? ([schema.default] as string[])
|
||||
: []
|
||||
)
|
||||
.map((example: any) => {
|
||||
return <option key={example} value={example} />;
|
||||
})}
|
||||
</datalist>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TbArrowDown, TbArrowUp, TbPlus, TbTrash } from "react-icons/tb";
|
||||
import { Button } from "../../../buttons/Button";
|
||||
import { IconButton } from "../../../buttons/IconButton";
|
||||
|
||||
export const AddButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<Button onClick={onClick} disabled={disabled} IconLeft={TbPlus}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RemoveButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbTrash} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveUpButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowUp} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveDownButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowDown} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type FieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { ucFirstAll, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
export type LabelProps = {
|
||||
/** The label for the field */
|
||||
label?: string;
|
||||
/** A boolean value stating if the field is required */
|
||||
required?: boolean;
|
||||
/** The id of the input field being labeled */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** Renders a label for a field
|
||||
*
|
||||
* @param props - The `LabelProps` for this component
|
||||
*/
|
||||
export function Label(props: LabelProps) {
|
||||
const { label, required, id } = props;
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<label className="control-label" htmlFor={id}>
|
||||
{ucFirstAllSnakeToPascalWithSpaces(label)}
|
||||
{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** The `FieldTemplate` component is the template used by `SchemaField` to render any field. It renders the field
|
||||
* content, (label, description, children, errors and help) inside of a `WrapIfAdditional` component.
|
||||
*
|
||||
* @param props - The `FieldTemplateProps` for this component
|
||||
*/
|
||||
export function FieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: FieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
registry,
|
||||
uiSchema
|
||||
} = props;
|
||||
const uiOptions = getUiOptions(uiSchema, registry.globalUiOptions);
|
||||
//console.log("field---", uiOptions);
|
||||
const WrapIfAdditionalTemplate = getTemplate<"WrapIfAdditionalTemplate", T, S, F>(
|
||||
"WrapIfAdditionalTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
//console.log("FieldTemplate", props);
|
||||
|
||||
return (
|
||||
<WrapIfAdditionalTemplate {...props}>
|
||||
{/*<Label label={label} required={required} id={id} />*/}
|
||||
<div className="flex flex-col flex-grow gap-2 additional">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-grow additional-children",
|
||||
uiOptions.flexDirection === "row"
|
||||
? "flex-row items-center gap-3"
|
||||
: "flex-col flex-grow gap-2"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{displayLabel && description ? description : null}
|
||||
</div>
|
||||
{errors}
|
||||
{help}
|
||||
</WrapIfAdditionalTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type ObjectFieldTemplatePropertyType,
|
||||
type ObjectFieldTemplateProps,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
canExpand,
|
||||
descriptionId,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
titleId
|
||||
} from "@rjsf/utils";
|
||||
|
||||
/** The `ObjectFieldTemplate` is the template to use to render all the inner properties of an object along with the
|
||||
* title and description if available. If the object is expandable, then an `AddButton` is also rendered after all
|
||||
* the properties.
|
||||
*
|
||||
* @param props - The `ObjectFieldTemplateProps` for this component
|
||||
*/
|
||||
export default function ObjectFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ObjectFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
description,
|
||||
disabled,
|
||||
formData,
|
||||
idSchema,
|
||||
onAddClick,
|
||||
properties,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title,
|
||||
uiSchema
|
||||
} = props;
|
||||
const options = getUiOptions<T, S, F>(uiSchema);
|
||||
const TitleFieldTemplate = getTemplate<"TitleFieldTemplate", T, S, F>(
|
||||
"TitleFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
const DescriptionFieldTemplate = getTemplate<"DescriptionFieldTemplate", T, S, F>(
|
||||
"DescriptionFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
|
||||
/* if (properties.length === 0) {
|
||||
return null;
|
||||
} */
|
||||
const _canExpand = canExpand(schema, uiSchema, formData);
|
||||
if (properties.length === 0 && !_canExpand) {
|
||||
return null;
|
||||
}
|
||||
//console.log("multi:properties", uiSchema, props, options);
|
||||
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
|
||||
return (
|
||||
<>
|
||||
<fieldset id={idSchema.$id} className="object-field">
|
||||
{title && (
|
||||
<TitleFieldTemplate
|
||||
id={titleId<T>(idSchema)}
|
||||
title={title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{description && (
|
||||
<DescriptionFieldTemplate
|
||||
id={descriptionId<T>(idSchema)}
|
||||
description={description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{properties.map((prop: ObjectFieldTemplatePropertyType) => prop.content)}
|
||||
{_canExpand && (
|
||||
<AddButton
|
||||
className="object-property-expand"
|
||||
onClick={onAddClick(schema)}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, TitleFieldProps } from "@rjsf/utils";
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
/** The `TitleField` is the template to use to render the title of a field
|
||||
*
|
||||
* @param props - The `TitleFieldProps` for this component
|
||||
*/
|
||||
export default function TitleField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: TitleFieldProps<T, S, F>) {
|
||||
const { id, title, required } = props;
|
||||
return (
|
||||
<legend id={id} className="title-field">
|
||||
{ucFirstAllSnakeToPascalWithSpaces(title)}
|
||||
{/*{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}*/}
|
||||
</legend>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type WrapIfAdditionalTemplateProps
|
||||
} from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
/** The `WrapIfAdditional` component is used by the `FieldTemplate` to rename, or remove properties that are
|
||||
* part of an `additionalProperties` part of a schema.
|
||||
*
|
||||
* @param props - The `WrapIfAdditionalProps` for this component
|
||||
*/
|
||||
export default function WrapIfAdditionalTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
classNames,
|
||||
style,
|
||||
disabled,
|
||||
label,
|
||||
onKeyChange,
|
||||
onDropPropertyClick,
|
||||
readonly,
|
||||
required,
|
||||
schema,
|
||||
children,
|
||||
uiSchema,
|
||||
registry
|
||||
} = props;
|
||||
const { templates, translateString } = registry;
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const { RemoveButton } = templates.ButtonTemplates;
|
||||
const keyLabel = translateString(TranslatableString.KeyLabel, [label]);
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
<div className="flex flex-col">
|
||||
<fieldset>
|
||||
<legend className="flex flex-row justify-between gap-3">
|
||||
<RemoveButton
|
||||
className="array-item-remove btn-block"
|
||||
style={{ border: "0" }}
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropPropertyClick(label)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
type="text"
|
||||
id={`${id}-key`}
|
||||
onBlur={(event) => onKeyChange(event.target.value)}
|
||||
defaultValue={label}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setExpanded((prev) => !prev)}>
|
||||
{expanded ? "collapse" : "expand"}
|
||||
</button>
|
||||
</legend>
|
||||
{expanded && (
|
||||
<div className="form-additional additional-start form-group">{children}</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArrayFieldItemTemplate from "./ArrayFieldItemTemplate";
|
||||
import ArrayFieldTemplate from "./ArrayFieldTemplate";
|
||||
import BaseInputTemplate from "./BaseInputTemplate";
|
||||
import * as ButtonTemplates from "./ButtonTemplates";
|
||||
import { FieldTemplate } from "./FieldTemplate";
|
||||
import ObjectFieldTemplate from "./ObjectFieldTemplate";
|
||||
import TitleFieldTemplate from "./TitleFieldTemplate";
|
||||
import WrapIfAdditionalTemplate from "./WrapIfAdditionalTemplate";
|
||||
|
||||
export const templates = {
|
||||
ButtonTemplates,
|
||||
ArrayFieldItemTemplate,
|
||||
ArrayFieldTemplate,
|
||||
FieldTemplate,
|
||||
TitleFieldTemplate,
|
||||
ObjectFieldTemplate,
|
||||
BaseInputTemplate,
|
||||
WrapIfAdditionalTemplate
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Check, Errors } from "core/utils";
|
||||
import { FromSchema } from "./from-schema";
|
||||
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorTransformer,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
|
||||
const validate = true;
|
||||
|
||||
export class RJSFTypeboxValidator<T = any, S extends StrictRJSFSchema = RJSFSchema>
|
||||
implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation(schema: S, formData?: T) {
|
||||
if (!validate) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
const tbSchema = FromSchema(schema as unknown);
|
||||
|
||||
//console.log("--validation", tbSchema, formData);
|
||||
|
||||
if (Check(tbSchema, formData)) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [...Errors(tbSchema, formData)],
|
||||
validationError: null as any
|
||||
};
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
|
||||
const transformedErrors = errors.map((error) => {
|
||||
const schemaLocation = error.path.substring(1).split("/").join(".");
|
||||
|
||||
return {
|
||||
name: "any",
|
||||
message: error.message,
|
||||
property: "." + schemaLocation,
|
||||
schemaPath: error.path,
|
||||
stack: error.message
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
const validation = this.rawValidation(schema, formData);
|
||||
|
||||
return validation.errors.length === 0;
|
||||
}
|
||||
|
||||
toErrorList(): RJSFValidationError[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@sinclair/typebox/prototypes
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---------------------------------------------------------------------------*/
|
||||
|
||||
import * as Type from "@sinclair/typebox";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Schematics
|
||||
// ------------------------------------------------------------------
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) ||
|
||||
Type.ValueGuard.IsNumber(value) ||
|
||||
Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
Type.ValueGuard.IsArray(value.enum) &&
|
||||
value.enum.every((value) => IsSValue(value));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
!Type.ValueGuard.IsArray(value.items) &&
|
||||
Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst =>
|
||||
// biome-ignore lint: reason
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSNumber = (value: unknown): value is SNumber =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{
|
||||
type: "object";
|
||||
properties: SProperties;
|
||||
required?: readonly string[];
|
||||
}>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
// ------------------------------------------------------------------
|
||||
// FromRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
// biome-ignore lint: reason
|
||||
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
|
||||
? TFromSchema<L> extends infer S extends Type.TSchema
|
||||
? TFromRest<R, [...Acc, S]>
|
||||
: TFromRest<R, [...Acc]>
|
||||
: Acc
|
||||
)
|
||||
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromEnumRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
|
||||
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
|
||||
: Acc
|
||||
)
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AllOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAllOf<T extends SAllOf> = (
|
||||
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TIntersectEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AnyOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAnyOf<T extends SAnyOf> = (
|
||||
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// OneOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromOneOf<T extends SOneOf> = (
|
||||
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Enum
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnum<T extends SEnum> = (
|
||||
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Elements>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Tuple
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromConst<T extends SConst> = (
|
||||
Type.Ensure<Type.TLiteral<T['const']>>
|
||||
)
|
||||
function FromConst<T extends SConst>(T: T) {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Object
|
||||
// ------------------------------------------------------------------
|
||||
type TFromPropertiesIsOptional<
|
||||
K extends PropertyKey,
|
||||
R extends string | unknown
|
||||
> = unknown extends R ? true : K extends R ? false : true;
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
|
||||
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
|
||||
? Type.TOptional<TFromSchema<T[K]>>
|
||||
: TFromSchema<T[K]>
|
||||
}>
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromObject<T extends SObject> = (
|
||||
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
|
||||
? Type.TObject<Properties>
|
||||
: Type.TObject<{}>
|
||||
)
|
||||
function FromObject<T extends SObject>(T: T): TFromObject<T> {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
|
||||
return {
|
||||
// biome-ignore lint:
|
||||
...Acc,
|
||||
[K]:
|
||||
// biome-ignore lint: reason
|
||||
T.required && T.required.includes(K)
|
||||
? FromSchema(T.properties[K])
|
||||
: Type.Optional(FromSchema(T.properties[K]))
|
||||
};
|
||||
}, {} as Type.TProperties);
|
||||
|
||||
if ("additionalProperties" in T) {
|
||||
return Type.Object(properties, {
|
||||
additionalProperties: FromSchema(T.additionalProperties)
|
||||
}) as never;
|
||||
}
|
||||
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromSchema
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
export type TFromSchema<T> = (
|
||||
T extends SAllOf ? TFromAllOf<T> :
|
||||
T extends SAnyOf ? TFromAnyOf<T> :
|
||||
T extends SOneOf ? TFromOneOf<T> :
|
||||
T extends SEnum ? TFromEnum<T> :
|
||||
T extends SObject ? TFromObject<T> :
|
||||
T extends STuple ? TFromTuple<T> :
|
||||
T extends SArray ? TFromArray<T> :
|
||||
T extends SConst ? TFromConst<T> :
|
||||
T extends SString ? Type.TString :
|
||||
T extends SNumber ? Type.TNumber :
|
||||
T extends SInteger ? Type.TInteger :
|
||||
T extends SBoolean ? Type.TBoolean :
|
||||
T extends SNull ? Type.TNull :
|
||||
Type.TUnknown
|
||||
)
|
||||
/** Parses a TypeBox type from raw JsonSchema */
|
||||
export function FromSchema<T>(T: T): TFromSchema<T> {
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
return (
|
||||
IsSAllOf(T) ? FromAllOf(T) :
|
||||
IsSAnyOf(T) ? FromAnyOf(T) :
|
||||
IsSOneOf(T) ? FromOneOf(T) :
|
||||
IsSEnum(T) ? FromEnum(T) :
|
||||
IsSObject(T) ? FromObject(T) :
|
||||
IsSTuple(T) ? FromTuple(T) :
|
||||
IsSArray(T) ? FromArray(T) :
|
||||
IsSConst(T) ? FromConst(T) :
|
||||
IsSString(T) ? Type.String(T) :
|
||||
IsSNumber(T) ? Type.Number(T) :
|
||||
IsSInteger(T) ? Type.Integer(T) :
|
||||
IsSBoolean(T) ? Type.Boolean(T) :
|
||||
IsSNull(T) ? Type.Null(T) :
|
||||
Type.Unknown(T || {})
|
||||
) as never
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps } from "@rjsf/utils";
|
||||
import { type ChangeEvent, useCallback } from "react";
|
||||
|
||||
export function CheckboxWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
uiSchema,
|
||||
options,
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
label,
|
||||
hideLabel,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
registry,
|
||||
...props
|
||||
}: WidgetProps<T, S, F>) {
|
||||
/*console.log("addprops", value, props, label, {
|
||||
label,
|
||||
name: props.name,
|
||||
hideLabel,
|
||||
label_lower: label.toLowerCase(),
|
||||
name_lower: props.name.toLowerCase(),
|
||||
equals: label.toLowerCase() === props.name.toLowerCase()
|
||||
});*/
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
onChange={handleChange}
|
||||
defaultChecked={value}
|
||||
disabled={disabled || readonly}
|
||||
label={label.toLowerCase() === props.name.toLowerCase() ? undefined : label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsDeselectValue,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsSelectValue,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `CheckboxesWidget` is a widget for rendering checkbox groups.
|
||||
* It is typically used to represent an array of enums.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function CheckboxesWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
id,
|
||||
disabled,
|
||||
options: { inline = false, enumOptions, enumDisabled, emptyValue },
|
||||
value,
|
||||
autofocus = false,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const checkboxesValues = Array.isArray(value) ? value : [value];
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
return (
|
||||
<div className="checkboxes" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, index) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, checkboxesValues);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
onChange(enumOptionsSelectValue<S>(index, checkboxesValues, enumOptions));
|
||||
} else {
|
||||
onChange(enumOptionsDeselectValue<S>(index, checkboxesValues, enumOptions));
|
||||
}
|
||||
};
|
||||
|
||||
const checkbox = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: it's wrapped
|
||||
<span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={optionId(id, index)}
|
||||
name={id}
|
||||
checked={checked}
|
||||
value={String(index)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && index === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
return inline ? (
|
||||
<label key={index} className={`checkbox-inline ${disabledCls}`}>
|
||||
{checkbox}
|
||||
</label>
|
||||
) : (
|
||||
<div key={index} className={`checkbox ${disabledCls}`}>
|
||||
<label>{checkbox}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxesWidget;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function JsonWidget({ value, onChange, disabled, readonly, ...props }: WidgetProps) {
|
||||
const [val, setVal] = useState(JSON.stringify(value, null, 2));
|
||||
|
||||
function handleChange(e) {
|
||||
setVal(e.target.value);
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea value={val} rows={10} disabled={disabled || readonly} onChange={handleChange} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `RadioWidget` is a widget for rendering a radio group.
|
||||
* It is typically used with a string property constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function RadioWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
id
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, inline, emptyValue } = options;
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="field-radio-group" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, i) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, value);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = () => onChange(option.value);
|
||||
|
||||
const radio = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
id={optionId(id, i)}
|
||||
checked={checked}
|
||||
name={id}
|
||||
required={required}
|
||||
value={String(i)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && i === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return inline ? (
|
||||
<label
|
||||
key={i}
|
||||
className={`radio-inline ${checked ? "checked" : ""} ${disabledCls}`}
|
||||
>
|
||||
{radio}
|
||||
</label>
|
||||
) : (
|
||||
<div key={i} className={`radio ${checked ? "checked" : ""} ${disabledCls}`}>
|
||||
<label>{radio}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RadioWidget;
|
||||
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIndexForValue,
|
||||
enumOptionsValueForIndex
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, type SyntheticEvent, useCallback } from "react";
|
||||
|
||||
function getValue(event: SyntheticEvent<HTMLSelectElement>, multiple: boolean) {
|
||||
if (multiple) {
|
||||
return Array.from((event.target as HTMLSelectElement).options)
|
||||
.slice()
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return (event.target as HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
/** The `SelectWidget` is a widget for rendering dropdowns.
|
||||
* It is typically used with string properties constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function SelectWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
id,
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
multiple = false,
|
||||
autofocus = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options;
|
||||
const emptyValue = multiple ? [] : "";
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onFocus(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onFocus, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onBlur(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onBlur, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onChange(enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onChange, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
|
||||
const showPlaceholderOption = !multiple && schema.default === undefined;
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
multiple={multiple}
|
||||
className="form-control"
|
||||
value={typeof selectedIndexes === "undefined" ? emptyValue : selectedIndexes}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
autoFocus={autofocus}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
>
|
||||
{showPlaceholderOption && <option value="">{placeholder}</option>}
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map(({ value, label }, i) => {
|
||||
const disabled = enumDisabled && enumDisabled.indexOf(value) !== -1;
|
||||
return (
|
||||
<option key={i} value={String(i)} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectWidget;
|
||||
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
import { CheckboxWidget } from "./CheckboxWidget";
|
||||
import CheckboxesWidget from "./CheckboxesWidget";
|
||||
import JsonWidget from "./JsonWidget";
|
||||
import RadioWidget from "./RadioWidget";
|
||||
import SelectWidget from "./SelectWidget";
|
||||
|
||||
const WithLabel = (WrappedComponent, kind?: string) => {
|
||||
return (props) => {
|
||||
const hideLabel =
|
||||
!props.label ||
|
||||
props.uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <Label label={props.label} required={props.required} id={props.id} />}
|
||||
<WrappedComponent {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const widgets = {
|
||||
RadioWidget: RadioWidget,
|
||||
CheckboxWidget: WithLabel(CheckboxWidget),
|
||||
SelectWidget: WithLabel(SelectWidget, "select"),
|
||||
CheckboxesWidget: WithLabel(CheckboxesWidget),
|
||||
JsonWidget: WithLabel(JsonWidget)
|
||||
};
|
||||
107
app/src/ui/components/list/SortableList.tsx
Normal file
107
app/src/ui/components/list/SortableList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
type DraggableProvided,
|
||||
type DraggableRubric,
|
||||
type DraggableStateSnapshot,
|
||||
Droppable,
|
||||
type DroppableProps
|
||||
} from "@hello-pangea/dnd";
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId } from "react";
|
||||
|
||||
export type SortableItemProps = {
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
rubic: DraggableRubric;
|
||||
};
|
||||
|
||||
type SortableListProps<Item = any> = ElementProps<"div"> & {
|
||||
data: Item[];
|
||||
extractId?: (item: Item) => string;
|
||||
renderItem?: (props: Item & SortableItemProps, index: number) => React.ReactNode;
|
||||
dndProps?: Omit<DroppableProps, "children">;
|
||||
onReordered?: (from: number, to: number) => void;
|
||||
onChange?: (data: Item[]) => void;
|
||||
disableIndices?: number[];
|
||||
};
|
||||
|
||||
export function SortableList({
|
||||
data = [],
|
||||
extractId,
|
||||
renderItem,
|
||||
dndProps = { droppableId: "sortable-list", direction: "vertical" },
|
||||
onReordered,
|
||||
onChange,
|
||||
disableIndices = [],
|
||||
...props
|
||||
}: SortableListProps) {
|
||||
//const [state, handlers] = useListState(data);
|
||||
|
||||
function onDragEnd({ destination, source }) {
|
||||
if (disableIndices.includes(source.index) || disableIndices.includes(destination.index))
|
||||
return;
|
||||
|
||||
const change = { from: source.index, to: destination?.index || 0 };
|
||||
//handlers.reorder(change);
|
||||
onReordered?.(change.from, change.to);
|
||||
}
|
||||
|
||||
/*function onDragUpdate({ destination, source }) {
|
||||
if (disableIndices.includes(source.index) || disableIndices.includes(destination.index))
|
||||
return;
|
||||
|
||||
const change = { from: source.index, to: destination?.index || 0 };
|
||||
//handlers.reorder(change);
|
||||
onReordered?.(change.from, change.to);
|
||||
}*/
|
||||
|
||||
/*useEffect(() => {
|
||||
handlers.setState(data);
|
||||
}, [data]);*/
|
||||
|
||||
const items = data.map((item, index) => {
|
||||
const id = extractId ? extractId(item) : useId();
|
||||
return (
|
||||
<Draggable
|
||||
key={id}
|
||||
index={index}
|
||||
draggableId={id}
|
||||
isDragDisabled={disableIndices.includes(index)}
|
||||
>
|
||||
{(provided, snapshot, rubic) =>
|
||||
renderItem ? (
|
||||
renderItem({ ...item, dnd: { provided, snapshot, rubic } }, index)
|
||||
) : (
|
||||
<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} /*onDragUpdate={onDragUpdate}*/>
|
||||
<Droppable {...dndProps}>
|
||||
{(provided) => (
|
||||
<div {...props} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{items}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
3
app/src/ui/components/menu/Dropdown.tsx
Normal file
3
app/src/ui/components/menu/Dropdown.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const DropDown = () => {
|
||||
return null;
|
||||
};
|
||||
0
app/src/ui/components/modal/Modal.tsx
Normal file
0
app/src/ui/components/modal/Modal.tsx
Normal file
154
app/src/ui/components/modal/Modal2.tsx
Normal file
154
app/src/ui/components/modal/Modal2.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Modal, type ModalProps, Popover } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
||||
import { TbX } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import { JsonViewer } from "../code/JsonViewer";
|
||||
|
||||
export type Modal2Ref = {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
export type Modal2Props = Omit<ModalProps, "opened" | "onClose"> & {
|
||||
opened?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Modal2 = forwardRef<Modal2Ref, Modal2Props>(
|
||||
(
|
||||
{ classNames, children, opened: initialOpened, closeOnClickOutside = false, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [opened, { open, close }] = useDisclosure(initialOpened);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open,
|
||||
close
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
withCloseButton={false}
|
||||
padding={0}
|
||||
size="xl"
|
||||
opened={opened}
|
||||
{...props}
|
||||
closeOnClickOutside={closeOnClickOutside}
|
||||
onClose={close}
|
||||
classNames={{
|
||||
...classNames,
|
||||
root: "bknd-admin",
|
||||
content: "rounded-lg select-none"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
|
||||
return (
|
||||
<div className="py-3 px-5 font-bold bg-primary/5 flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-b border-b-muted">
|
||||
<div className="flex flex-row gap-1">
|
||||
{path.map((p, i) => {
|
||||
const last = i + 1 === path.length;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<span key={i} className={twMerge("", !last && "opacity-70")}>
|
||||
{p}
|
||||
</span>
|
||||
{!last && <span className="opacity-40">/</span>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<IconButton Icon={TbX} onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalBody = ({ children, className }: { children?: any; className?: string }) => (
|
||||
<ScrollArea.Root
|
||||
className={twMerge("flex flex-col min-h-96", className)}
|
||||
style={{
|
||||
maxHeight: "calc(80vh)"
|
||||
}}
|
||||
>
|
||||
<ScrollArea.Viewport className="w-full h-full">
|
||||
<div className="py-3 px-5 gap-4 flex flex-col">{children}</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-muted flex-col h-0.5"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50 " />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
|
||||
export const ModalFooter = ({
|
||||
next,
|
||||
prev,
|
||||
nextLabel = "Next",
|
||||
prevLabel = "Back",
|
||||
debug
|
||||
}: {
|
||||
next: any;
|
||||
prev: any;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
debug?: any;
|
||||
}) => {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
return (
|
||||
<div className="flex flex-col border-t border-t-muted">
|
||||
<div className="flex flex-row justify-between items-center py-3 px-4">
|
||||
<div>
|
||||
{debug && (
|
||||
<Popover
|
||||
position="right-start"
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
classNames={{
|
||||
dropdown: "!px-1 !pr-2.5 !py-2 text-sm"
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<IconButton
|
||||
onClick={handlers.toggle}
|
||||
Icon={IconBug}
|
||||
variant={opened ? "default" : "ghost"}
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer json={debug} expand={6} className="p-0" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button className="w-24 justify-center" {...prev}>
|
||||
{prevLabel}
|
||||
</Button>
|
||||
<Button className="w-24 justify-center" variant="primary" {...next}>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
app/src/ui/components/overlay/Dropdown.tsx
Normal file
111
app/src/ui/components/overlay/Dropdown.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { Fragment, type ReactElement, cloneElement, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type DropdownItem =
|
||||
| (() => JSX.Element)
|
||||
| {
|
||||
label: string | ReactElement;
|
||||
icon?: any;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type DropdownProps = {
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
hideOnEmpty?: boolean;
|
||||
items: (DropdownItem | undefined | boolean)[];
|
||||
itemsClassName?: string;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
onClickItem?: (item: DropdownItem) => void;
|
||||
renderItem?: (
|
||||
item: DropdownItem,
|
||||
props: { key: number; onClick: () => void }
|
||||
) => ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
defaultOpen = false,
|
||||
position = "bottom-start",
|
||||
items,
|
||||
hideOnEmpty = true,
|
||||
onClickItem,
|
||||
renderItem,
|
||||
itemsClassName,
|
||||
className
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
const menuItems = items.filter(Boolean) as DropdownItem[];
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
||||
);
|
||||
|
||||
const offset = 4;
|
||||
const dropdownStyle = {
|
||||
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
||||
"bottom-end": { right: 0, top: "100%", marginTop: offset },
|
||||
"top-start": { bottom: "100%", marginBottom: offset },
|
||||
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
|
||||
}[position];
|
||||
|
||||
const internalOnClickItem = useEvent((item) => {
|
||||
if (item.onClick) item.onClick();
|
||||
if (onClickItem) onClickItem(item);
|
||||
toggle(50);
|
||||
});
|
||||
|
||||
if (menuItems.length === 0 && hideOnEmpty) return null;
|
||||
const space_for_icon = menuItems.some((item) => "icon" in item && item.icon);
|
||||
|
||||
const itemRenderer =
|
||||
renderItem ||
|
||||
((item, { key, onClick }) =>
|
||||
typeof item === "function" ? (
|
||||
<Fragment key={key}>{item()}</Fragment>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={item.disabled}
|
||||
className={twMerge(
|
||||
"flex flex-row flex-nowrap text-nowrap items-center outline-none cursor-pointer px-2.5 rounded-md link leading-none h-8",
|
||||
itemsClassName,
|
||||
item.disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-primary/10",
|
||||
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{space_for_icon && (
|
||||
<div className="size-[16px] text-left mr-1.5 opacity-80">
|
||||
{item.icon && <item.icon className="size-[16px]" />}
|
||||
</div>
|
||||
)}
|
||||
{/*{item.icon && <item.icon className="size-4" />}*/}
|
||||
<div className="flex flex-grow truncate text-nowrap">{item.label}</div>
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{menuItems.map((item, i) =>
|
||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/src/ui/components/overlay/Modal.tsx
Normal file
48
app/src/ui/components/overlay/Modal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { type ReactElement, cloneElement, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type ModalProps = {
|
||||
open?: boolean;
|
||||
stickToTop?: boolean;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
allowBackdropClose?: boolean;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open = false,
|
||||
children,
|
||||
onClose = () => null,
|
||||
allowBackdropClose = true,
|
||||
className,
|
||||
stickToTop
|
||||
}: ModalProps) {
|
||||
const clickoutsideRef = useClickOutside(() => {
|
||||
if (allowBackdropClose) onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-full fixed bottom-0 top-0 right-0 left-0 bg-background/60 flex justify-center backdrop-blur-sm z-10",
|
||||
stickToTop ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"z-20 flex flex-col bg-background rounded-lg shadow-lg",
|
||||
className
|
||||
)}
|
||||
ref={clickoutsideRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
app/src/ui/components/overlay/Popover.tsx
Normal file
57
app/src/ui/components/overlay/Popover.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { type ReactElement, cloneElement, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type PopoverProps = {
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
backdrop?: boolean;
|
||||
target: (props: { toggle: () => void }) => ReactElement;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Popover({
|
||||
children,
|
||||
target,
|
||||
defaultOpen = false,
|
||||
backdrop = false,
|
||||
position = "bottom-start",
|
||||
className,
|
||||
}: PopoverProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
|
||||
);
|
||||
|
||||
const pos = {
|
||||
"bottom-start": "mt-1 top-[100%]",
|
||||
"bottom-end": "right-0 top-[100%] mt-1",
|
||||
"top-start": "bottom-[100%] mb-1",
|
||||
"top-end": "bottom-[100%] right-0 mb-1",
|
||||
}[position];
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && backdrop && (
|
||||
<div className="animate-fade-in w-full h-full absolute top-0 bottom-0 right-0 left-0 bg-background/60" />
|
||||
)}
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
{open && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full max-w-20 backdrop-blur-sm",
|
||||
pos,
|
||||
)}
|
||||
>
|
||||
{target({ toggle })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
app/src/ui/components/radix/ScrollArea.tsx
Normal file
22
app/src/ui/components/radix/ScrollArea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
|
||||
|
||||
export const ScrollArea = ({ children, className }: any) => (
|
||||
<ReactScrollArea.Root className={`${className} `}>
|
||||
<ReactScrollArea.Viewport className="w-full h-full ">
|
||||
{children}
|
||||
</ReactScrollArea.Viewport>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Corner className="ScrollAreaCorner" />
|
||||
</ReactScrollArea.Root>
|
||||
);
|
||||
86
app/src/ui/components/radix/extend.tsx
Normal file
86
app/src/ui/components/radix/extend.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type ElementType,
|
||||
type ForwardedRef,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
forwardRef
|
||||
} from "react";
|
||||
|
||||
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
|
||||
Component: ComponentType,
|
||||
applyAdditionalProps?: (
|
||||
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
}
|
||||
) => ComponentProps<ComponentType>
|
||||
) {
|
||||
return forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
// Initialize newProps with a default empty object or the result of applyAdditionalProps
|
||||
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
|
||||
? applyAdditionalProps(props as any)
|
||||
: (props as any);
|
||||
|
||||
// Append className if it exists in both props and newProps
|
||||
if (props.className && newProps.className) {
|
||||
newProps = {
|
||||
...newProps,
|
||||
className: `${props.className} ${newProps.className}`
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error haven't figured out the correct typing
|
||||
return <Component {...newProps} ref={ref} />;
|
||||
});
|
||||
}
|
||||
|
||||
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
|
||||
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
},
|
||||
ref: ForwardedRef<ElementRef<ComponentType>>
|
||||
) => ReactElement;
|
||||
|
||||
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
|
||||
renderFunction: RenderFunction<ComponentType, AdditionalProps>
|
||||
) {
|
||||
// The extended component using forwardRef to forward the ref to the custom component
|
||||
const ExtendedComponent = forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
return renderFunction(props as any, ref);
|
||||
});
|
||||
|
||||
return ExtendedComponent;
|
||||
}
|
||||
|
||||
/*
|
||||
export const Content = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Content>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Content
|
||||
className={`flex flex-col ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
|
||||
export const Item = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Item>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Item
|
||||
className={`flex flex-row flex-nowrap ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
*/
|
||||
67
app/src/ui/components/steps/Steps.tsx
Normal file
67
app/src/ui/components/steps/Steps.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Children,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
createContext,
|
||||
useContext,
|
||||
useState
|
||||
} from "react";
|
||||
|
||||
export type TStepsProps = {
|
||||
children: any;
|
||||
initialPath?: string[];
|
||||
lastBack?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type TStepContext<T = any> = {
|
||||
nextStep: (step: string) => () => void;
|
||||
stepBack: () => void;
|
||||
close: () => void;
|
||||
state: T;
|
||||
setState: Dispatch<SetStateAction<T>>;
|
||||
};
|
||||
|
||||
const StepContext = createContext<TStepContext>(undefined as any);
|
||||
|
||||
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>({});
|
||||
const [path, setPath] = useState<string[]>(initialPath);
|
||||
const steps: any[] = Children.toArray(children).filter(
|
||||
(child: any) => child.props.disabled !== true
|
||||
);
|
||||
|
||||
function stepBack() {
|
||||
if (path.length === 0) {
|
||||
lastBack?.();
|
||||
} else {
|
||||
setPath((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = (step: string) => () => {
|
||||
setPath((prev) => [...prev, step]);
|
||||
};
|
||||
|
||||
const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0];
|
||||
|
||||
return (
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}>
|
||||
{current}
|
||||
</StepContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStepContext<T = any>(): TStepContext<T> {
|
||||
return useContext(StepContext);
|
||||
}
|
||||
|
||||
export function Step({
|
||||
children,
|
||||
disabled = false,
|
||||
path = [],
|
||||
id,
|
||||
...rest
|
||||
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
|
||||
return <div {...rest}>{children}</div>;
|
||||
}
|
||||
297
app/src/ui/components/table/DataTable.tsx
Normal file
297
app/src/ui/components/table/DataTable.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Menu } from "@mantine/core";
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import { ucFirst } from "core/utils";
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbDotsVertical,
|
||||
TbSelector,
|
||||
TbSquare,
|
||||
TbSquareCheckFilled
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import { Dropdown, type DropdownItem } from "../overlay/Dropdown";
|
||||
|
||||
export const Check = () => {
|
||||
const [checked, toggle] = useToggle([false, true]);
|
||||
const Icon = checked ? TbSquareCheckFilled : TbSquare;
|
||||
return (
|
||||
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type DataTableProps<Data> = {
|
||||
data: Data[];
|
||||
columns?: string[];
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: Data) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
rowActions?: (Omit<DropdownItem, "onClick"> & {
|
||||
onClick: (row: Data, key: number) => void;
|
||||
})[];
|
||||
perPageOptions?: number[];
|
||||
sort?: { by?: string; dir?: "asc" | "desc" };
|
||||
onClickSort?: (name: string) => void;
|
||||
onClickPerPage?: (perPage: number) => void;
|
||||
renderHeader?: (column: string) => React.ReactNode;
|
||||
renderValue?: ({ value, property }: { value: any; property: string }) => React.ReactNode;
|
||||
classNames?: {
|
||||
value?: string;
|
||||
};
|
||||
onClickNew?: () => void;
|
||||
};
|
||||
|
||||
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
|
||||
data = [],
|
||||
columns,
|
||||
checkable,
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
perPageOptions,
|
||||
onClickPerPage,
|
||||
classNames,
|
||||
renderHeader,
|
||||
rowActions,
|
||||
renderValue,
|
||||
onClickNew
|
||||
}: DataTableProps<Data>) {
|
||||
total = total || data.length;
|
||||
page = page || 1;
|
||||
|
||||
const select = columns && columns.length > 0 ? columns : Object.keys(data[0] || {});
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{onClickNew && (
|
||||
<div className="flex flex-row space-between">
|
||||
{onClickNew && <Button onClick={onClickNew}>Create new</Button>}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
|
||||
<table className="w-full">
|
||||
{select.length > 0 ? (
|
||||
<thead className="sticky top-0 bg-muted/10">
|
||||
<tr>
|
||||
{checkable && (
|
||||
<th align="center" className="w-[40px]">
|
||||
<Check />
|
||||
</th>
|
||||
)}
|
||||
{select.map((property, key) => {
|
||||
const label = renderHeader?.(property) ?? ucFirst(property);
|
||||
|
||||
return (
|
||||
<th key={key}>
|
||||
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
|
||||
<button
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"link hover:bg-primary/5 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1",
|
||||
onClickSort ? "pl-2.5 pr-1" : "px-2.5"
|
||||
)}
|
||||
onClick={() => onClickSort?.(property)}
|
||||
>
|
||||
<span className="text-left text-nowrap whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
{onClickSort && (
|
||||
<SortIndicator sort={sort} field={property} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{rowActions && rowActions.length > 0 && <th className="w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
) : null}
|
||||
<tbody>
|
||||
{!data || data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
||||
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
||||
<i className="opacity-50">No data to show</i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, key) => {
|
||||
const rowClick = () => onClickRow?.(row);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
data-border={key > 0}
|
||||
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
|
||||
>
|
||||
{checkable && (
|
||||
<td align="center">
|
||||
<Check />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{Object.entries(row).map(([key, value], index) => (
|
||||
<td key={index} onClick={rowClick}>
|
||||
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
|
||||
<CellRender property={key} value={value} />
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{rowActions && rowActions.length > 0 && (
|
||||
<td>
|
||||
{/* @todo: create new dropdown using popover */}
|
||||
<div className="flex flex-row justify-end pr-2">
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<IconButton Icon={TbDotsVertical} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{rowActions.map((a: any) => (
|
||||
<Menu.Item
|
||||
key={a.label}
|
||||
onClick={() => a.onClick(row, key)}
|
||||
leftSection={a.icon && <a.icon />}
|
||||
>
|
||||
{a.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
|
||||
Per Page{" "}
|
||||
<Dropdown
|
||||
items={perPageOptions.map((perPage) => ({
|
||||
label: String(perPage),
|
||||
perPage
|
||||
}))}
|
||||
position="top-end"
|
||||
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
|
||||
>
|
||||
<Button>{perPage}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CellValue = ({ value, property }) => {
|
||||
let value_mono = false;
|
||||
//console.log("value", property, value);
|
||||
if (value !== null && typeof value === "object") {
|
||||
value = JSON.stringify(value);
|
||||
value_mono = true;
|
||||
}
|
||||
|
||||
if (value !== null && typeof value !== "undefined") {
|
||||
return <span className={twMerge("line-clamp-2", value_mono && "font-mono")}>{value}</span>;
|
||||
}
|
||||
|
||||
return <span className="opacity-10 font-mono">null</span>;
|
||||
};
|
||||
|
||||
const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<DataTableProps<any>, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
|
||||
return <TbArrowDown size={18} className="mt-[1px]" />;
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total }
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
};
|
||||
79
app/src/ui/components/wouter/Link.tsx
Normal file
79
app/src/ui/components/wouter/Link.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useInsertionEffect, useRef } from "react";
|
||||
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
/*
|
||||
* Transforms `path` into its relative `base` version
|
||||
* If base isn't part of the path provided returns absolute path e.g. `~/app`
|
||||
*/
|
||||
export const relativePath = (base = "", path = "") =>
|
||||
!path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path;
|
||||
|
||||
export const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to);
|
||||
|
||||
/*
|
||||
* Removes leading question mark
|
||||
*/
|
||||
export const stripQm = (str) => (str[0] === "?" ? str.slice(1) : str);
|
||||
|
||||
/*
|
||||
* decodes escape sequences such as %20
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
|
||||
export const unescape = (str) => {
|
||||
try {
|
||||
return decodeURI(str);
|
||||
} catch (_e) {
|
||||
// fail-safe mode: if string can't be decoded do nothing
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const useLocationFromRouter = (router) => {
|
||||
const [location, navigate] = router.hook(router);
|
||||
|
||||
// the function reference should stay the same between re-renders, so that
|
||||
// it can be passed down as an element prop without any performance concerns.
|
||||
// (This is achieved via `useEvent`.)
|
||||
return [
|
||||
unescape(relativePath(router.base, location)),
|
||||
useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts))
|
||||
];
|
||||
};
|
||||
|
||||
export function Link({ className, ...props }: { className?: string } & LinkProps) {
|
||||
const router = useRouter();
|
||||
const [path, navigate] = useLocationFromRouter(router);
|
||||
|
||||
function isActive(absPath: string, href: string) {
|
||||
if (absPath.startsWith(href)) {
|
||||
const l = absPath.replace(href, "");
|
||||
return l.length === 0 || l[0] === "/";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleClick(e) {}
|
||||
|
||||
const _href = props.href ?? props.to;
|
||||
const href = router
|
||||
.hrefs(
|
||||
_href[0] === "~" ? _href.slice(1) : router.base + _href,
|
||||
router // pass router as a second argument for convinience
|
||||
)
|
||||
.replace("//", "/");
|
||||
const absPath = absolutePath(path, router.base).replace("//", "/");
|
||||
const active =
|
||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||
const a = useRoute(_href);
|
||||
|
||||
/*if (active) {
|
||||
console.log("link", { a, path, absPath, href, to, active, router });
|
||||
}*/
|
||||
return (
|
||||
// @ts-expect-error className is not typed on WouterLink
|
||||
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} />
|
||||
);
|
||||
}
|
||||
95
app/src/ui/container/EntitiesContainer.tsx
Normal file
95
app/src/ui/container/EntitiesContainer.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { UseQueryOptions, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { RepositoryResponse } from "data";
|
||||
import type { RepoQuery } from "data";
|
||||
import { useClient } from "../client";
|
||||
import { type EntityData, type QueryStatus, getStatus } from "./EntityContainer";
|
||||
|
||||
export type RenderParams<Data extends EntityData = EntityData> = {
|
||||
data: Data[] | undefined;
|
||||
meta: RepositoryResponse["meta"] | undefined;
|
||||
status: {
|
||||
fetch: QueryStatus;
|
||||
};
|
||||
raw: {
|
||||
fetch: UseQueryResult;
|
||||
};
|
||||
actions: {
|
||||
create(obj: any): any;
|
||||
update(id: number, obj: any): any;
|
||||
};
|
||||
};
|
||||
|
||||
export type EntitiesContainerProps = {
|
||||
entity: string;
|
||||
query?: Partial<RepoQuery>;
|
||||
queryOptions?: Partial<UseQueryOptions>;
|
||||
};
|
||||
|
||||
export function useEntities(
|
||||
entity: string,
|
||||
query?: Partial<RepoQuery>,
|
||||
queryOptions?: Partial<UseQueryOptions>
|
||||
): RenderParams {
|
||||
const client = useClient();
|
||||
let data: any = null;
|
||||
let meta: any = null;
|
||||
|
||||
const fetchQuery = client.query(queryOptions).data.entity(entity).readMany(query);
|
||||
const createMutation = client.mutation.data.entity(entity).create();
|
||||
const updateMutation = (id: number) => client.mutation.data.entity(entity).update(id);
|
||||
|
||||
if (fetchQuery?.isSuccess) {
|
||||
meta = fetchQuery.data?.body.meta;
|
||||
data = fetchQuery.data?.body.data;
|
||||
}
|
||||
|
||||
function create(obj: any) {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
return new Promise(async (resolve, reject) => {
|
||||
await createMutation?.mutate(obj, {
|
||||
onSuccess: resolve,
|
||||
onError: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function update(id: number, obj: any) {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
return new Promise(async (resolve, reject) => {
|
||||
await updateMutation(id).mutate(obj, {
|
||||
onSuccess: resolve,
|
||||
onError: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
meta,
|
||||
actions: {
|
||||
create,
|
||||
update
|
||||
// remove
|
||||
},
|
||||
status: {
|
||||
fetch: getStatus(fetchQuery)
|
||||
},
|
||||
raw: {
|
||||
fetch: fetchQuery
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function EntitiesContainer<Data extends EntityData = EntityData>({
|
||||
entity,
|
||||
query,
|
||||
queryOptions,
|
||||
children
|
||||
}: EntitiesContainerProps & {
|
||||
children(params: RenderParams<Data>): any;
|
||||
}) {
|
||||
const params = useEntities(entity, query, queryOptions);
|
||||
return children(params as any);
|
||||
}
|
||||
|
||||
export const Entities = EntitiesContainer;
|
||||
136
app/src/ui/container/EntityContainer.tsx
Normal file
136
app/src/ui/container/EntityContainer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import type { RepoQuery } from "data";
|
||||
import { useClient } from "../client";
|
||||
|
||||
export type EntityData = Record<string, any>;
|
||||
|
||||
export type EntityContainerRenderParams<Data extends EntityData = EntityData> = {
|
||||
data: Data | null;
|
||||
client: ReturnType<typeof useClient>;
|
||||
initialValues: object;
|
||||
raw: {
|
||||
fetch?: UseQueryResult;
|
||||
};
|
||||
status: {
|
||||
fetch: QueryStatus;
|
||||
};
|
||||
actions: {
|
||||
create(obj: any): any;
|
||||
update(obj: any): any;
|
||||
remove(): any;
|
||||
};
|
||||
};
|
||||
|
||||
export type MutationStatus = {
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export type QueryStatus = MutationStatus & {
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
export function getStatus(query?: UseQueryResult): QueryStatus {
|
||||
return {
|
||||
isLoading: query ? query.isPending : false,
|
||||
isUpdating: query ? !query.isInitialLoading && query.isFetching : false,
|
||||
isSuccess: query ? query.isSuccess : false,
|
||||
isError: query ? query.isError : false
|
||||
};
|
||||
}
|
||||
|
||||
export type EntityContainerProps = {
|
||||
entity: string;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
type FetchOptions = {
|
||||
disabled?: boolean;
|
||||
query?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>;
|
||||
};
|
||||
|
||||
// @todo: add option to disable fetches (for form updates)
|
||||
// @todo: must return a way to indicate error!
|
||||
export function useEntity<Data extends EntityData = EntityData>(
|
||||
entity: string,
|
||||
id?: number,
|
||||
options?: { fetch?: FetchOptions }
|
||||
): EntityContainerRenderParams<Data> {
|
||||
const client = useClient();
|
||||
let data: any = null;
|
||||
|
||||
const fetchQuery = id
|
||||
? client.query().data.entity(entity).readOne(id, options?.fetch?.query)
|
||||
: undefined;
|
||||
const createMutation = id ? null : client.mutation.data.entity(entity).create();
|
||||
const updateMutation = id ? client.mutation.data.entity(entity).update(id) : undefined;
|
||||
const deleteMutation = id ? client.mutation.data.entity(entity).delete(id) : undefined;
|
||||
|
||||
if (fetchQuery?.isSuccess) {
|
||||
data = fetchQuery.data?.body.data;
|
||||
}
|
||||
|
||||
const initialValues = { one: 1 };
|
||||
|
||||
function create(obj: any) {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
return new Promise(async (resolve, reject) => {
|
||||
await createMutation?.mutate(obj, {
|
||||
onSuccess: resolve,
|
||||
onError: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function update(obj: any) {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
return new Promise(async (resolve, reject) => {
|
||||
//await new Promise((r) => setTimeout(r, 4000));
|
||||
await updateMutation?.mutate(obj, {
|
||||
onSuccess: resolve,
|
||||
onError: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function remove() {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
return new Promise(async (resolve, reject) => {
|
||||
//await new Promise((r) => setTimeout(r, 4000));
|
||||
await deleteMutation?.mutate(undefined, {
|
||||
onSuccess: resolve,
|
||||
onError: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
client,
|
||||
initialValues,
|
||||
actions: {
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
},
|
||||
status: {
|
||||
fetch: getStatus(fetchQuery)
|
||||
//update: getMutationStatus(updateMutation),
|
||||
},
|
||||
raw: {
|
||||
fetch: fetchQuery
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function EntityContainer({
|
||||
entity,
|
||||
id,
|
||||
children
|
||||
}: EntityContainerProps & { children(params: EntityContainerRenderParams): any }) {
|
||||
const params = useEntity(entity, id);
|
||||
return children(params);
|
||||
}
|
||||
|
||||
export const Entity = EntityContainer;
|
||||
2
app/src/ui/container/index.ts
Normal file
2
app/src/ui/container/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./EntitiesContainer";
|
||||
export * from "./EntityContainer";
|
||||
22
app/src/ui/container/use-flows.ts
Normal file
22
app/src/ui/container/use-flows.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useBknd } from "../client/BkndProvider";
|
||||
|
||||
/** @deprecated */
|
||||
export function useFlows() {
|
||||
const { app } = useBknd();
|
||||
|
||||
return {
|
||||
flows: app.flows,
|
||||
config: app.config.flows
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function useFlow(name: string) {
|
||||
const { app } = useBknd();
|
||||
const flow = app.flows.find((f) => f.name === name);
|
||||
|
||||
return {
|
||||
flow: flow!,
|
||||
config: app.config.flows[name]
|
||||
};
|
||||
}
|
||||
8
app/src/ui/hooks/use-browser-title.ts
Normal file
8
app/src/ui/hooks/use-browser-title.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
export function useBrowserTitle(path: string[] = []) {
|
||||
useLayoutEffect(() => {
|
||||
const prefix = "BKND";
|
||||
document.title = [prefix, ...path].join(" / ");
|
||||
});
|
||||
}
|
||||
18
app/src/ui/hooks/use-event.ts
Normal file
18
app/src/ui/hooks/use-event.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Userland polyfill while we wait for the forthcoming
|
||||
// https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
|
||||
// Note: "A high-fidelity polyfill for useEvent is not possible because
|
||||
// there is no lifecycle or Hook in React that we can use to switch
|
||||
// .current at the right timing."
|
||||
// So we will have to make do with this "close enough" approach for now.
|
||||
import { useInsertionEffect, useRef } from "react";
|
||||
|
||||
export const useEvent = <Fn>(fn: Fn | ((...args: any[]) => any) | undefined): Fn => {
|
||||
const ref = useRef([fn, (...args) => ref[0](...args)]).current;
|
||||
// Per Dan Abramov: useInsertionEffect executes marginally closer to the
|
||||
// correct timing for ref synchronization than useLayoutEffect on React 18.
|
||||
// See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360
|
||||
useInsertionEffect(() => {
|
||||
ref[0] = fn;
|
||||
});
|
||||
return ref[1];
|
||||
};
|
||||
47
app/src/ui/hooks/use-search.ts
Normal file
47
app/src/ui/hooks/use-search.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
Type,
|
||||
decodeSearch,
|
||||
encodeSearch,
|
||||
parseDecode
|
||||
} from "core/utils";
|
||||
import { isEqual, transform } from "lodash-es";
|
||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
export function useSearch<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
defaultValue?: Partial<StaticDecode<Schema>>
|
||||
) {
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||
|
||||
if (searchString.length > 0) {
|
||||
value = parseDecode(schema, decodeSearch(searchString));
|
||||
//console.log("search:decode", value);
|
||||
}
|
||||
|
||||
// @todo: add option to set multiple keys at once
|
||||
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
|
||||
//console.log("set", key, value);
|
||||
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
|
||||
const search = transform(
|
||||
update as any,
|
||||
(result, value, key) => {
|
||||
if (defaultValue && isEqual(value, defaultValue[key])) return;
|
||||
result[key] = value;
|
||||
},
|
||||
{} as Static<Schema>
|
||||
);
|
||||
const encoded = encodeSearch(search, { encode: false });
|
||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
||||
}
|
||||
|
||||
return {
|
||||
value: value as Required<StaticDecode<Schema>>,
|
||||
set
|
||||
};
|
||||
}
|
||||
21
app/src/ui/index.ts
Normal file
21
app/src/ui/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { default as Admin } from "./Admin";
|
||||
export { Button } from "./components/buttons/Button";
|
||||
export { Context } from "./components/Context";
|
||||
export {
|
||||
useClient,
|
||||
ClientProvider,
|
||||
BkndProvider,
|
||||
useBknd,
|
||||
useAuth,
|
||||
useBaseUrl
|
||||
} from "./client";
|
||||
export {
|
||||
EntitiesContainer,
|
||||
useEntities,
|
||||
type EntitiesContainerProps
|
||||
} from "./container/EntitiesContainer";
|
||||
export {
|
||||
EntityContainer,
|
||||
useEntity,
|
||||
type EntityContainerProps
|
||||
} from "./container/EntityContainer";
|
||||
362
app/src/ui/layouts/AppShell/AppShell.tsx
Normal file
362
app/src/ui/layouts/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useClickOutside, useDisclosure, useHotkeys, useViewportSize } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { throttle } from "lodash-es";
|
||||
import { type ComponentProps, createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { Link } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export function Root({ children }) {
|
||||
return (
|
||||
<AppShellProvider>
|
||||
<div data-shell="root" className="flex flex-1 flex-col select-none h-dvh">
|
||||
{children}
|
||||
</div>
|
||||
</AppShellProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps<E extends React.ElementType> = {
|
||||
Icon?: IconType;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
to?: string; // @todo: workaround
|
||||
as?: E;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const NavLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
Icon,
|
||||
disabled,
|
||||
...otherProps
|
||||
}: NavLinkProps<E> & Omit<React.ComponentProps<E>, keyof NavLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...otherProps}
|
||||
className={twMerge(
|
||||
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={18} />}
|
||||
{typeof children === "string" ? <span className="text-lg">{children}</span> : children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export function Content({ children, center }: { children: React.ReactNode; center?: boolean }) {
|
||||
return (
|
||||
<main
|
||||
data-shell="content"
|
||||
className={twMerge(
|
||||
"flex flex-1 flex-row w-dvw h-full",
|
||||
center && "justify-center items-center"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function Main({ children }) {
|
||||
return (
|
||||
<div data-shell="main" className="flex flex-col flex-grow w-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ children }) {
|
||||
const ctx = useAppShell();
|
||||
|
||||
const ref = useClickOutside(ctx.sidebar?.handler?.close);
|
||||
|
||||
const onClickBackdrop = useEvent((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx?.sidebar?.handler.close();
|
||||
});
|
||||
|
||||
const onEscape = useEvent(() => {
|
||||
if (ctx?.sidebar?.open) {
|
||||
ctx?.sidebar?.handler.close();
|
||||
}
|
||||
});
|
||||
|
||||
// @todo: potentially has to be added to the root, as modals could be opened
|
||||
useHotkeys([["Escape", onEscape]]);
|
||||
|
||||
if (!ctx) {
|
||||
console.warn("AppShell.Sidebar: missing AppShellContext");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
data-shell="sidebar"
|
||||
className="hidden md:flex flex-col basis-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-muted/10"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
<div
|
||||
data-open={ctx?.sidebar?.open}
|
||||
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
|
||||
onClick={onClickBackdrop}
|
||||
>
|
||||
<aside
|
||||
/*ref={ref}*/
|
||||
data-shell="sidebar"
|
||||
className="flex-col w-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) {
|
||||
return (
|
||||
<h2
|
||||
{...props}
|
||||
className={twMerge("text-lg dark:font-bold font-semibold select-text", className)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeader({ children, right, className, scrollable, sticky }: any = {}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row h-14 flex-shrink-0 py-2 pl-5 pr-3 border-muted border-b items-center justify-between bg-muted/10",
|
||||
sticky && "sticky top-0 bottom-10 z-10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"",
|
||||
scrollable && "overflow-x-scroll overflow-y-visible app-scrollbar"
|
||||
)}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<SectionHeaderTitle>{children}</SectionHeaderTitle>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
{right && !scrollable && <div className="flex flex-row gap-2.5">{right}</div>}
|
||||
{right && scrollable && (
|
||||
<div className="flex flex-row sticky z-10 right-0 h-full">
|
||||
<div className="h-full w-5 bg-gradient-to-l from-background" />
|
||||
<div className="flex flex-row gap-2.5 bg-background">{right}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarLinkProps<E extends React.ElementType> = {
|
||||
children: React.ReactNode;
|
||||
as?: E;
|
||||
to?: string; // @todo: workaround
|
||||
params?: Record<string, string>; // @todo: workaround
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
disabled = false,
|
||||
...otherProps
|
||||
}: SidebarLinkProps<E> & Omit<React.ComponentProps<E>, keyof SidebarLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...otherProps}
|
||||
className={twMerge(
|
||||
"flex flex-row px-4 py-2.5 items-center gap-2",
|
||||
!disabled &&
|
||||
"cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 link",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionHeaderLinkProps<E extends React.ElementType> = {
|
||||
children: React.ReactNode;
|
||||
as?: E;
|
||||
to?: string; // @todo: workaround
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
badge?: string | number;
|
||||
};
|
||||
|
||||
export const SectionHeaderLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
disabled = false,
|
||||
active = false,
|
||||
badge,
|
||||
...props
|
||||
}: SectionHeaderLinkProps<E> & Omit<React.ComponentProps<E>, keyof SectionHeaderLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"hover:bg-primary/5 flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg",
|
||||
active
|
||||
? "bg-background hover:bg-background text-primary border border-muted border-b-0"
|
||||
: "link",
|
||||
badge && "pr-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{badge ? (
|
||||
<span className="px-3 py-1 rounded-full font-mono bg-primary/5 text-sm leading-none">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export type SectionHeaderTabsProps = {
|
||||
title?: string;
|
||||
items?: (Omit<SectionHeaderLinkProps<any>, "children"> & {
|
||||
label: string;
|
||||
})[];
|
||||
};
|
||||
export const SectionHeaderTabs = ({ title, items }: SectionHeaderTabsProps) => {
|
||||
return (
|
||||
<SectionHeader className="mt-10 border-t pl-3 pb-0 items-end">
|
||||
<div className="flex flex-row items-center gap-6 -mb-px">
|
||||
{title && (
|
||||
<SectionHeaderTitle className="pl-2 hidden md:block">{title}</SectionHeaderTitle>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{items?.map(({ label, ...item }, key) => (
|
||||
<SectionHeaderLink key={key} {...item}>
|
||||
{label}
|
||||
</SectionHeaderLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export function Scrollable({
|
||||
children,
|
||||
initialOffset = 64
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialOffset?: number;
|
||||
}) {
|
||||
const scrollRef = useRef<React.ElementRef<"div">>(null);
|
||||
const [offset, setOffset] = useState(initialOffset);
|
||||
|
||||
function updateHeaderHeight() {
|
||||
if (scrollRef.current) {
|
||||
setOffset(scrollRef.current.offsetTop);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(updateHeaderHeight, []);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("resize", throttle(updateHeaderHeight, 500));
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
|
||||
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-muted flex-col h-0.5"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50 " />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
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 bg-muted/10 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"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { Header } from "./Header";
|
||||
116
app/src/ui/layouts/AppShell/Breadcrumbs.tsx
Normal file
116
app/src/ui/layouts/AppShell/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useMemo } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { IconButton } from "../../components/buttons/IconButton";
|
||||
import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type BreadcrumbsProps = {
|
||||
path: string | string[];
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const Breadcrumbs = ({ path: _path, backTo, onBack }: BreadcrumbsProps) => {
|
||||
const [_, navigate] = useLocation();
|
||||
const location = window.location.pathname;
|
||||
const path = Array.isArray(_path) ? _path : [_path];
|
||||
const loc = location.split("/").filter((v) => v !== "");
|
||||
const hasBack = path.length > 1;
|
||||
|
||||
const goBack = onBack
|
||||
? onBack
|
||||
: useEvent(() => {
|
||||
if (backTo) {
|
||||
navigate(backTo, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const href = loc.slice(0, path.length + 1).join("/");
|
||||
navigate(`~/${href}`, { replace: true });
|
||||
});
|
||||
|
||||
const crumbs = useMemo(
|
||||
() =>
|
||||
path.map((p, key) => {
|
||||
const last = key === path.length - 1;
|
||||
const index = loc.indexOf(p);
|
||||
const href = loc.slice(0, index + 1).join("/");
|
||||
const string = ucFirstAllSnakeToPascalWithSpaces(p);
|
||||
|
||||
return {
|
||||
last,
|
||||
href,
|
||||
string
|
||||
};
|
||||
}),
|
||||
[path, loc]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow items-center gap-2 text-lg">
|
||||
{hasBack && (
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
Icon={TbArrowLeft}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:flex gap-2">
|
||||
<CrumbsDesktop crumbs={crumbs} />
|
||||
</div>
|
||||
<div className="flex md:hidden gap-2">{<CrumbsMobile crumbs={crumbs} />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbsDesktop = ({ crumbs }) => {
|
||||
return crumbs.map((crumb, key) => {
|
||||
return crumb.last ? <CrumbLast key={key} {...crumb} /> : <CrumbLink key={key} {...crumb} />;
|
||||
});
|
||||
};
|
||||
|
||||
const CrumbsMobile = ({ crumbs }) => {
|
||||
const [, navigate] = useLocation();
|
||||
if (crumbs.length <= 2) return <CrumbsDesktop crumbs={crumbs} />;
|
||||
const first = crumbs[0];
|
||||
const last = crumbs[crumbs.length - 1];
|
||||
const items = useMemo(
|
||||
() =>
|
||||
crumbs.slice(1, -1).map((c) => ({
|
||||
label: c.string,
|
||||
href: c.href
|
||||
})),
|
||||
[crumbs]
|
||||
);
|
||||
const onClick = useEvent((item) => navigate(`~/${item.href}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrumbLink {...first} />
|
||||
<Dropdown onClickItem={onClick} items={items}>
|
||||
<IconButton Icon={TbDots} variant="ghost" />
|
||||
</Dropdown>
|
||||
<span className="opacity-25 dark:font-bold font-semibold">/</span>
|
||||
<CrumbLast {...last} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbLast = ({ string }) => {
|
||||
return <span className="text-nowrap dark:font-bold font-semibold">{string}</span>;
|
||||
};
|
||||
|
||||
const CrumbLink = ({ href, string }) => {
|
||||
return (
|
||||
<div className="opacity-50 flex flex-row gap-2 dark:font-bold font-semibold">
|
||||
<Link to={`~/${href}`} className="text-nowrap">
|
||||
{string}
|
||||
</Link>
|
||||
<span className="opacity-50">/</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
app/src/ui/layouts/AppShell/Breadcrumbs2.tsx
Normal file
120
app/src/ui/layouts/AppShell/Breadcrumbs2.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useMemo } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { IconButton } from "../../components/buttons/IconButton";
|
||||
import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
type Breadcrumb = {
|
||||
label: string | JSX.Element;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
export type Breadcrumbs2Props = {
|
||||
path: Breadcrumb[];
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const Breadcrumbs2 = ({ path: _path, backTo, onBack }: Breadcrumbs2Props) => {
|
||||
const [_, navigate] = useLocation();
|
||||
const location = window.location.pathname;
|
||||
const path = Array.isArray(_path) ? _path : [_path];
|
||||
const loc = location.split("/").filter((v) => v !== "");
|
||||
const hasBack = path.length > 1;
|
||||
|
||||
const goBack = onBack
|
||||
? onBack
|
||||
: useEvent(() => {
|
||||
if (backTo) {
|
||||
navigate(backTo, { replace: true });
|
||||
return;
|
||||
} else if (_path.length > 0 && _path[0]?.href) {
|
||||
navigate(_path[0].href, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const href = loc.slice(0, path.length + 1).join("/");
|
||||
navigate(`~/${href}`, { replace: true });
|
||||
});
|
||||
|
||||
const crumbs = useMemo(
|
||||
() =>
|
||||
path.map((p, key) => {
|
||||
const last = key === path.length - 1;
|
||||
|
||||
return {
|
||||
last,
|
||||
...p
|
||||
};
|
||||
}),
|
||||
[path]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow items-center gap-2 text-lg">
|
||||
{hasBack && (
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
Icon={TbArrowLeft}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:flex gap-1.5">
|
||||
<CrumbsDesktop crumbs={crumbs} />
|
||||
</div>
|
||||
<div className="flex md:hidden gap-2">{<CrumbsMobile crumbs={crumbs} />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbsDesktop = ({ crumbs }) => {
|
||||
return crumbs.map((crumb, key) => {
|
||||
return crumb.last ? <CrumbLast key={key} {...crumb} /> : <CrumbLink key={key} {...crumb} />;
|
||||
});
|
||||
};
|
||||
|
||||
const CrumbsMobile = ({ crumbs }) => {
|
||||
const [, navigate] = useLocation();
|
||||
if (crumbs.length <= 2) return <CrumbsDesktop crumbs={crumbs} />;
|
||||
const first = crumbs[0];
|
||||
const last = crumbs[crumbs.length - 1];
|
||||
const items = useMemo(
|
||||
() =>
|
||||
crumbs.slice(1, -1).map((c) => ({
|
||||
label: c.string,
|
||||
href: c.href
|
||||
})),
|
||||
[crumbs]
|
||||
);
|
||||
const onClick = useEvent((item) => navigate(`~/${item.href}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrumbLink {...first} />
|
||||
<Dropdown onClickItem={onClick} items={items}>
|
||||
<IconButton Icon={TbDots} variant="ghost" />
|
||||
</Dropdown>
|
||||
<span className="opacity-25 dark:font-bold font-semibold">/</span>
|
||||
<CrumbLast {...last} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbLast = ({ label }) => {
|
||||
return <span className="text-nowrap dark:font-bold font-semibold">{label}</span>;
|
||||
};
|
||||
|
||||
const CrumbLink = ({ href, label }) => {
|
||||
return (
|
||||
<div className="opacity-50 flex flex-row gap-1.5 dark:font-bold font-semibold">
|
||||
<Link to={href} className="text-nowrap">
|
||||
{label}
|
||||
</Link>
|
||||
<span className="opacity-50">/</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
app/src/ui/layouts/AppShell/Header.tsx
Normal file
203
app/src/ui/layouts/AppShell/Header.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Menu, Popover, SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
TbFingerprint,
|
||||
TbHierarchy2,
|
||||
TbMenu2,
|
||||
TbMoon,
|
||||
TbPhoto,
|
||||
TbSelector,
|
||||
TbSun,
|
||||
TbUser,
|
||||
TbX
|
||||
} from "react-icons/tb";
|
||||
import { Button } from "ui";
|
||||
import { useAuth, useBknd } from "ui/client";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
|
||||
function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
exact?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
/*{
|
||||
label: "Base",
|
||||
href: "#",
|
||||
exact: true,
|
||||
Icon: TbLayoutDashboard,
|
||||
disabled: true,
|
||||
tooltip: "Coming soon"
|
||||
},*/
|
||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||
{ label: "Media", href: "/media", Icon: TbPhoto },
|
||||
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 }
|
||||
];
|
||||
const activeItem = items.find((item) =>
|
||||
item.exact ? location === item.href : location.startsWith(item.href)
|
||||
);
|
||||
|
||||
const handleItemClick = useEvent((item) => {
|
||||
navigate(item.href);
|
||||
});
|
||||
|
||||
const renderDropdownItem = (item, { key, onClick }) => (
|
||||
<NavLink key={key} onClick={onClick} as="button" className="rounded-md">
|
||||
<div
|
||||
data-active={activeItem?.label === item.label}
|
||||
className="flex flex-row items-center gap-2.5 data-[active=true]:opacity-50"
|
||||
>
|
||||
<item.Icon size={18} />
|
||||
<span className="text-lg">{item.label}</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{items.map((item) => (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={item.tooltip}
|
||||
disabled={typeof item.tooltip === "undefined"}
|
||||
position="bottom"
|
||||
>
|
||||
<div>
|
||||
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex md:hidden flex-row items-center">
|
||||
{activeItem && (
|
||||
<Dropdown
|
||||
items={items}
|
||||
onClickItem={handleItemClick}
|
||||
renderItem={renderDropdownItem}
|
||||
>
|
||||
<NavLink as="button" Icon={activeItem.Icon} className="active pl-6 pr-3.5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span className="text-lg">{activeItem.label}</span>
|
||||
<TbSelector size={18} className="opacity-70" />
|
||||
</div>
|
||||
</NavLink>
|
||||
</Dropdown>
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarToggler() {
|
||||
const { sidebar } = useAppShell();
|
||||
return (
|
||||
<IconButton size="lg" Icon={sidebar.open ? TbX : TbMenu2} onClick={sidebar.handler.toggle} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ hasSidebar = true }) {
|
||||
//const logoReturnPath = "";
|
||||
const { app } = useBknd();
|
||||
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/";
|
||||
|
||||
return (
|
||||
<header
|
||||
data-shell="header"
|
||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||
>
|
||||
<Link
|
||||
href={logoReturnPath}
|
||||
replace
|
||||
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
<HeaderNavigation />
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex md:hidden flex-row items-center pr-2 gap-2">
|
||||
<SidebarToggler />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-row items-center px-4 gap-2">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu() {
|
||||
const auth = useAuth();
|
||||
const [navigate] = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
navigate("/auth/login", { replace: true });
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
navigate("/auth/login");
|
||||
}
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||
];
|
||||
|
||||
if (!auth.user) {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
||||
}
|
||||
|
||||
items.push(() => <UserMenuThemeToggler />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown items={items} position="bottom-end">
|
||||
{auth.user ? (
|
||||
<Button className="rounded-full w-12 h-12 justify-center p-0 text-lg">
|
||||
{auth.user.email[0]?.toUpperCase()}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="rounded-full w-12 h-12 justify-center p-0" IconLeft={TbUser} />
|
||||
)}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenuThemeToggler() {
|
||||
const { theme, toggle } = useBkndSystemTheme();
|
||||
return (
|
||||
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
data={[
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" }
|
||||
]}
|
||||
value={theme}
|
||||
onChange={toggle}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
app/src/ui/layouts/AppShell/index.ts
Normal file
1
app/src/ui/layouts/AppShell/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as AppShell from "./AppShell";
|
||||
28
app/src/ui/layouts/AppShell/use-appshell.tsx
Normal file
28
app/src/ui/layouts/AppShell/use-appshell.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useDisclosure, useViewportSize } from "@mantine/hooks";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type AppShellContextType = {
|
||||
sidebar: {
|
||||
open: boolean;
|
||||
handler: ReturnType<typeof useDisclosure>[1];
|
||||
};
|
||||
};
|
||||
|
||||
const AppShellContext = createContext<AppShellContextType>(undefined as any);
|
||||
|
||||
export function AppShellProvider({ children }) {
|
||||
const { width } = useViewportSize(); // @todo: maybe with throttle, not a problem atm
|
||||
const [sidebarOpen, sidebarHandlers] = useDisclosure(width > 768);
|
||||
|
||||
return (
|
||||
<AppShellContext.Provider
|
||||
value={{ sidebar: { open: sidebarOpen, handler: sidebarHandlers } }}
|
||||
>
|
||||
{children}
|
||||
</AppShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
return useContext(AppShellContext);
|
||||
}
|
||||
130
app/src/ui/lib/mantine/theme.ts
Normal file
130
app/src/ui/lib/mantine/theme.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Button,
|
||||
type ComboboxProps,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Switch,
|
||||
TagsInput,
|
||||
TextInput,
|
||||
Textarea,
|
||||
createTheme
|
||||
} from "@mantine/core";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// default: https://github.com/mantinedev/mantine/blob/master/src/mantine-core/src/core/MantineProvider/default-theme.ts
|
||||
|
||||
export function createMantineTheme(scheme: "light" | "dark"): {
|
||||
theme: ReturnType<typeof createTheme>;
|
||||
forceColorScheme: "light" | "dark";
|
||||
} {
|
||||
const light = scheme === "light";
|
||||
const dark = !light;
|
||||
const baseComboboxProps: ComboboxProps = {
|
||||
offset: 2,
|
||||
transitionProps: { transition: "pop", duration: 75 }
|
||||
};
|
||||
|
||||
const input =
|
||||
"bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500";
|
||||
|
||||
return {
|
||||
theme: createTheme({
|
||||
components: {
|
||||
Button: Button.extend({
|
||||
vars: (theme, props) => ({
|
||||
// https://mantine.dev/styles/styles-api/
|
||||
root: {
|
||||
"--button-height": "auto"
|
||||
}
|
||||
}),
|
||||
classNames: (theme, props) => ({
|
||||
root: twMerge("px-3 py-2 rounded-md h-auto")
|
||||
}),
|
||||
defaultProps: {
|
||||
size: "md",
|
||||
variant: light ? "filled" : "white"
|
||||
}
|
||||
}),
|
||||
Switch: Switch.extend({
|
||||
defaultProps: {
|
||||
size: "md",
|
||||
color: light ? "dark" : "blue"
|
||||
}
|
||||
}),
|
||||
Select: Select.extend({
|
||||
classNames: (theme, props) => ({
|
||||
//input: "focus:border-primary/50 bg-transparent disabled:text-primary",
|
||||
input,
|
||||
dropdown: `bknd-admin ${scheme} bg-background border-primary/20`
|
||||
}),
|
||||
defaultProps: {
|
||||
checkIconPosition: "right",
|
||||
comboboxProps: baseComboboxProps
|
||||
}
|
||||
}),
|
||||
TagsInput: TagsInput.extend({
|
||||
defaultProps: {
|
||||
comboboxProps: baseComboboxProps
|
||||
}
|
||||
}),
|
||||
Radio: Radio.extend({
|
||||
defaultProps: {
|
||||
classNames: {
|
||||
body: "items-center"
|
||||
}
|
||||
}
|
||||
}),
|
||||
TextInput: TextInput.extend({
|
||||
classNames: (theme, props) => ({
|
||||
wrapper: "leading-none",
|
||||
//input: "focus:border-primary/50 bg-transparent disabled:text-primary"
|
||||
input
|
||||
})
|
||||
}),
|
||||
NumberInput: NumberInput.extend({
|
||||
classNames: (theme, props) => ({
|
||||
wrapper: "leading-none",
|
||||
input
|
||||
})
|
||||
}),
|
||||
Textarea: Textarea.extend({
|
||||
classNames: (theme, props) => ({
|
||||
wrapper: "leading-none",
|
||||
input
|
||||
})
|
||||
}),
|
||||
Modal: Modal.extend({
|
||||
classNames: (theme, props) => ({
|
||||
...props.classNames,
|
||||
root: `bknd-admin ${scheme} ${props.className ?? ""} `,
|
||||
content: "bg-lightest border border-primary/10",
|
||||
overlay: "backdrop-blur"
|
||||
})
|
||||
}),
|
||||
Menu: Menu.extend({
|
||||
defaultProps: {
|
||||
offset: 2
|
||||
},
|
||||
|
||||
classNames: (theme, props) => ({
|
||||
dropdown: "!rounded-lg !px-1",
|
||||
item: "!rounded-md !text-[14px]"
|
||||
})
|
||||
}),
|
||||
SegmentedControl: SegmentedControl.extend({
|
||||
classNames: (theme, props) => ({
|
||||
root: light ? "bg-primary/5" : "bg-lightest/60",
|
||||
indicator: light ? "bg-background" : "bg-primary/15"
|
||||
})
|
||||
})
|
||||
},
|
||||
primaryColor: "dark",
|
||||
primaryShade: 9
|
||||
}),
|
||||
forceColorScheme: scheme
|
||||
};
|
||||
}
|
||||
128
app/src/ui/lib/routes.ts
Normal file
128
app/src/ui/lib/routes.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { encodeSearch } from "core/utils";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useBaseUrl } from "../client";
|
||||
import { useBknd } from "../client/BkndProvider";
|
||||
|
||||
export const routes = {
|
||||
data: {
|
||||
root: () => "/data",
|
||||
entity: {
|
||||
list: (entity: string) => `/entity/${entity}`,
|
||||
create: (entity: string) => `/entity/${entity}/create`,
|
||||
edit: (entity: string, id: PrimaryFieldType) => `/entity/${entity}/edit/${id}`
|
||||
},
|
||||
schema: {
|
||||
root: () => "/schema",
|
||||
entity: (entity: string) => `/schema/entity/${entity}`
|
||||
}
|
||||
},
|
||||
auth: {
|
||||
root: () => "/auth",
|
||||
users: {
|
||||
list: () => "/users",
|
||||
edit: (id: PrimaryFieldType) => `/users/edit/${id}`
|
||||
},
|
||||
roles: {
|
||||
list: () => "/roles",
|
||||
edit: (role: string) => `/roles/edit/${role}`
|
||||
},
|
||||
settings: () => "/settings",
|
||||
strategies: () => "/strategies"
|
||||
},
|
||||
flows: {
|
||||
root: () => "/flows",
|
||||
flows: {
|
||||
list: () => "/",
|
||||
edit: (id: PrimaryFieldType) => `/flow/${id}`
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
root: () => "/settings",
|
||||
path: (path: string[]) => `/settings/${path.join("/")}`
|
||||
}
|
||||
};
|
||||
|
||||
export function withQuery(url: string, query: object) {
|
||||
const search = encodeSearch(query, { encode: false });
|
||||
return `${url}?${search}`;
|
||||
}
|
||||
|
||||
export function withAbsolute(url: string) {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
return `~/${basepath}/${url}`.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
export function useNavigate() {
|
||||
const [location, navigate] = useLocation();
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
return [
|
||||
(
|
||||
url: string,
|
||||
options?: { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
||||
) => {
|
||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||
navigate(options?.query ? withQuery(_url, options?.query) : _url, {
|
||||
replace: options?.replace,
|
||||
state: options?.state
|
||||
});
|
||||
},
|
||||
location
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useGoBack(
|
||||
fallback: string | (() => void) = "/",
|
||||
options?: {
|
||||
native?: boolean;
|
||||
absolute?: boolean;
|
||||
}
|
||||
) {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
const [navigate] = useNavigate();
|
||||
const referrer = document.referrer;
|
||||
const history_length = window.history.length;
|
||||
const same = referrer.length === 0;
|
||||
const canGoBack = (same && history_length > 1) || !!same;
|
||||
|
||||
/*console.log("debug", {
|
||||
referrer,
|
||||
history_length,
|
||||
same,
|
||||
canGoBack
|
||||
});*/
|
||||
|
||||
function goBack() {
|
||||
if (same && history_length > 2) {
|
||||
//console.log("used history");
|
||||
window.history.back();
|
||||
} else {
|
||||
//console.log("used fallback");
|
||||
if (typeof fallback === "string") {
|
||||
const _fallback = options?.absolute
|
||||
? `~/${basepath}${fallback}`.replace(/\/+/g, "/")
|
||||
: fallback;
|
||||
//console.log("fallback", _fallback);
|
||||
|
||||
if (options?.native) {
|
||||
window.location.href = _fallback;
|
||||
} else {
|
||||
navigate(_fallback);
|
||||
}
|
||||
} else if (typeof fallback === "function") {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
same,
|
||||
canGoBack,
|
||||
goBack
|
||||
};
|
||||
}
|
||||
5
app/src/ui/lib/utils.ts
Normal file
5
app/src/ui/lib/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { type ClassNameValue, twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassNameValue[]) {
|
||||
return twMerge(inputs);
|
||||
}
|
||||
38
app/src/ui/main.tsx
Normal file
38
app/src/ui/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
import Admin from "./Admin";
|
||||
|
||||
function ClientApp() {
|
||||
return <Admin withProvider />;
|
||||
}
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById("app")!;
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ClientApp />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
// REGISTER ERROR OVERLAY
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const showErrorOverlay = (err) => {
|
||||
// must be within function call because that's when the element is defined for sure.
|
||||
const ErrorOverlay = customElements.get("vite-error-overlay");
|
||||
// don't open outside vite environment
|
||||
if (!ErrorOverlay) {
|
||||
return;
|
||||
}
|
||||
//console.log("error", err);
|
||||
const overlay = new ErrorOverlay(err);
|
||||
document.body.appendChild(overlay);
|
||||
};
|
||||
|
||||
window.addEventListener("error", ({ error }) => showErrorOverlay(error));
|
||||
window.addEventListener("unhandledrejection", ({ reason }) => showErrorOverlay(reason));
|
||||
}
|
||||
74
app/src/ui/modals/debug/DebugModal.tsx
Normal file
74
app/src/ui/modals/debug/DebugModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { type ModalProps, Tabs } from "@mantine/core";
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { transformObject } from "core/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { JsonViewer } from "../../components/code/JsonViewer";
|
||||
|
||||
type JsonViewerProps = Omit<ComponentProps<typeof JsonViewer>, "title" | "json">;
|
||||
type Primitive = object | string | number | boolean | any[];
|
||||
type DebugProps = {
|
||||
data: {
|
||||
[key: string]: ({ label: string; value: Primitive } & JsonViewerProps) | Primitive;
|
||||
};
|
||||
} & JsonViewerProps;
|
||||
|
||||
export function DebugModal({ innerProps }: ContextModalProps<DebugProps>) {
|
||||
const { data, ...jsonViewerProps } = innerProps;
|
||||
const tabs = transformObject(data, (item, name) => {
|
||||
if (typeof item === "object" && "label" in item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
label: <span className="font-mono">{name}</span>,
|
||||
value: item,
|
||||
expand: 10,
|
||||
showCopy: true,
|
||||
...jsonViewerProps
|
||||
};
|
||||
});
|
||||
|
||||
const count = Object.keys(tabs).length;
|
||||
function renderTab({ value, label, ...props }: (typeof tabs)[keyof typeof tabs]) {
|
||||
return <JsonViewer json={value as any} {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
{count > 1 ? (
|
||||
<Tabs defaultValue={Object.keys(tabs)[0]}>
|
||||
<div className="sticky top-0 bg-background z-10">
|
||||
<Tabs.List>
|
||||
{Object.entries(tabs).map(([key, tab]) => (
|
||||
<Tabs.Tab key={key} value={key}>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</div>
|
||||
{Object.entries(tabs).map(([key, tab]) => (
|
||||
<Tabs.Panel key={key} value={key}>
|
||||
{renderTab(tab)}
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
renderTab({
|
||||
// @ts-expect-error
|
||||
...tabs[Object.keys(tabs)[0]],
|
||||
// @ts-expect-error
|
||||
title: tabs[Object.keys(tabs)[0]].label
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DebugModal.defaultTitle = false;
|
||||
DebugModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
size: "lg",
|
||||
classNames: {
|
||||
body: "!p-0"
|
||||
}
|
||||
} satisfies Omit<ModalProps, "opened" | "onClose">;
|
||||
69
app/src/ui/modals/debug/SchemaFormModal.tsx
Normal file
69
app/src/ui/modals/debug/SchemaFormModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "ui";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormProps,
|
||||
type JsonSchemaFormRef
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
|
||||
type Props = JsonSchemaFormProps & {
|
||||
onSubmit?: (data: any) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function SchemaFormModal({
|
||||
context,
|
||||
id,
|
||||
innerProps: { schema, uiSchema, onSubmit }
|
||||
}: ContextModalProps<Props>) {
|
||||
const [valid, setValid] = useState(false);
|
||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||
|
||||
function handleChange(data) {
|
||||
const valid = formRef.current?.validateForm() ?? false;
|
||||
console.log("Data changed", data, valid);
|
||||
setValid(valid);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
context.closeModal(id);
|
||||
}
|
||||
|
||||
async function handleClickAdd() {
|
||||
await onSubmit?.(formRef.current?.formData());
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col">
|
||||
<JsonSchemaForm
|
||||
tagName="form"
|
||||
ref={formRef}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
onChange={handleChange}
|
||||
onSubmit={handleClickAdd}
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleClickAdd} disabled={!valid}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SchemaFormModal.defaultTitle = "JSON Schema Form Modal";
|
||||
SchemaFormModal.modalProps = {
|
||||
classNames: {
|
||||
size: "md",
|
||||
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: "!p-0"
|
||||
}
|
||||
};
|
||||
22
app/src/ui/modals/debug/TestModal.tsx
Normal file
22
app/src/ui/modals/debug/TestModal.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
|
||||
export function TestModal({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) {
|
||||
return (
|
||||
<>
|
||||
<span>{innerProps.modalBody}</span>
|
||||
<button onClick={() => context.closeModal(id)}>Close modal</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TestModal.defaultTitle = "Test Modal";
|
||||
TestModal.modalProps = {
|
||||
classNames: {
|
||||
size: "md",
|
||||
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: "py-3 px-5 gap-4 flex flex-col"
|
||||
}
|
||||
};
|
||||
57
app/src/ui/modals/index.tsx
Normal file
57
app/src/ui/modals/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { ModalsProvider, modals as mantineModals } from "@mantine/modals";
|
||||
import { transformObject } from "core/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { DebugModal } from "./debug/DebugModal";
|
||||
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
||||
import { TestModal } from "./debug/TestModal";
|
||||
|
||||
const modals = {
|
||||
test: TestModal,
|
||||
debug: DebugModal,
|
||||
form: SchemaFormModal
|
||||
};
|
||||
|
||||
declare module "@mantine/modals" {
|
||||
export interface MantineModalsOverride {
|
||||
modals: typeof modals;
|
||||
}
|
||||
}
|
||||
|
||||
export function BkndModalsProvider({ children }) {
|
||||
return (
|
||||
<ModalsProvider modals={modals} modalProps={{ className: "bknd-admin" }}>
|
||||
{children}
|
||||
</ModalsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function open<Modal extends keyof typeof modals>(
|
||||
modal: Modal,
|
||||
innerProps: ComponentProps<(typeof modals)[Modal]>["innerProps"],
|
||||
{ title: _title, ...modalProps }: Partial<ModalProps> = {}
|
||||
) {
|
||||
const title = _title ?? modals[modal].defaultTitle ?? undefined;
|
||||
const cmpModalProps = modals[modal].modalProps ?? {};
|
||||
return mantineModals.openContextModal({
|
||||
title,
|
||||
...modalProps,
|
||||
...cmpModalProps,
|
||||
modal,
|
||||
innerProps
|
||||
});
|
||||
}
|
||||
|
||||
function close<Modal extends keyof typeof modals>(modal: Modal) {
|
||||
return mantineModals.close(modal);
|
||||
}
|
||||
|
||||
export const bkndModals = {
|
||||
ids: transformObject(modals, (key) => key) as unknown as Record<
|
||||
keyof typeof modals,
|
||||
keyof typeof modals
|
||||
>,
|
||||
open,
|
||||
close,
|
||||
closeAll: mantineModals.closeAll
|
||||
};
|
||||
117
app/src/ui/modules/auth/LoginForm.tsx
Normal file
117
app/src/ui/modules/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { type FieldApi, useForm } from "@tanstack/react-form";
|
||||
import { Type, type TypeInvalidError, parse } from "core/utils";
|
||||
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
type LoginFormProps = {
|
||||
onSubmitted?: (value: { email: string; password: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginForm({ onSubmitted }: LoginFormProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: ""
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
onSubmitted?.(value);
|
||||
},
|
||||
defaultState: {
|
||||
canSubmit: false,
|
||||
isValid: false
|
||||
},
|
||||
validatorAdapter: () => {
|
||||
function validate(
|
||||
{ value, fieldApi }: { value: any; fieldApi: FieldApi<any, any> },
|
||||
fn: any
|
||||
): any {
|
||||
if (fieldApi.form.state.submissionAttempts === 0) return;
|
||||
|
||||
try {
|
||||
parse(fn, value);
|
||||
} catch (e) {
|
||||
return (e as TypeInvalidError).errors
|
||||
.map((error) => error.schema.error ?? error.message)
|
||||
.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return { validate, validateAsync: validate };
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate>
|
||||
<form.Field
|
||||
name="email"
|
||||
validators={{
|
||||
onChange: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||
})
|
||||
}}
|
||||
children={(field) => (
|
||||
<Formy.Group error={field.state.meta.errors.length > 0}>
|
||||
<Formy.Label htmlFor={field.name}>Email address</Formy.Label>
|
||||
<Formy.Input
|
||||
type="email"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
</Formy.Group>
|
||||
)}
|
||||
/>
|
||||
<form.Field
|
||||
name="password"
|
||||
validators={{
|
||||
onChange: Type.String({
|
||||
minLength: 8
|
||||
})
|
||||
}}
|
||||
children={(field) => (
|
||||
<Formy.Group error={field.state.meta.errors.length > 0}>
|
||||
<Formy.Label htmlFor={field.name}>Password</Formy.Label>
|
||||
<Formy.Input
|
||||
type="password"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
</Formy.Group>
|
||||
)}
|
||||
/>
|
||||
<form.Subscribe
|
||||
selector={(state) => {
|
||||
//console.log("state", state, Object.values(state.fieldMeta));
|
||||
const fieldMeta = Object.values(state.fieldMeta).map((f) => f.isDirty);
|
||||
const allDirty = fieldMeta.length > 0 ? fieldMeta.reduce((a, b) => a && b) : false;
|
||||
return [allDirty, state.isSubmitting];
|
||||
}}
|
||||
children={([allDirty, isSubmitting]) => {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full mt-2 justify-center"
|
||||
disabled={!allDirty || isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
329
app/src/ui/modules/data/components/EntityForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type { FieldApi, FormApi } from "@tanstack/react-form";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
EnumField,
|
||||
type Field,
|
||||
JsonField,
|
||||
JsonSchemaField,
|
||||
RelationField
|
||||
} from "data";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { useClient } from "ui/client";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "../../media/helper";
|
||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
Form: FormApi<any>;
|
||||
className?: string;
|
||||
action: "create" | "update";
|
||||
};
|
||||
|
||||
export function EntityForm({
|
||||
entity,
|
||||
entityId,
|
||||
handleSubmit,
|
||||
fieldsDisabled,
|
||||
Form,
|
||||
data,
|
||||
className,
|
||||
action
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
console.log("data", { data, fields });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Subscribe
|
||||
selector={(state) => {
|
||||
//console.log("state", state);
|
||||
return [state.canSubmit, state.isValid, state.errors];
|
||||
}}
|
||||
children={([canSubmit, isValid, errors]) => {
|
||||
//console.log("form:state", { canSubmit, isValid, errors });
|
||||
return (
|
||||
!isValid && (
|
||||
<div className="flex flex-col dark:bg-red-950 bg-red-100 p-4">
|
||||
<p>Form is invalid.</p>
|
||||
{Array.isArray(errors) && (
|
||||
<ul className="list-disc">
|
||||
{errors.map((error, key) => (
|
||||
<li className="ml-6" key={key}>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={className}>
|
||||
{fields.map((field, key) => {
|
||||
// @todo: tanstack form re-uses the state, causes issues navigating between entities w/ same fields
|
||||
|
||||
// media field needs to render outside of the form
|
||||
// as its value is not stored in the form state
|
||||
if (field instanceof MediaField) {
|
||||
return (
|
||||
<EntityMediaFormField
|
||||
key={field.name + key}
|
||||
entity={entity}
|
||||
entityId={entityId}
|
||||
formApi={Form}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.isFillable(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _key = `${entity.name}-${field.name}-${key}`;
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
key={_key}
|
||||
name={field.name}
|
||||
children={(props) => (
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="hidden">
|
||||
<button type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type EntityFormFieldProps<
|
||||
T extends keyof JSX.IntrinsicElements = "input",
|
||||
F extends Field = Field
|
||||
> = ComponentProps<T> & {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: F;
|
||||
action: "create" | "update";
|
||||
data?: EntityData;
|
||||
};
|
||||
|
||||
type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
|
||||
if (typeof e === "object" && "target" in e) {
|
||||
console.log("handleUpdate", e.target.value);
|
||||
fieldApi.handleChange(e.target.value);
|
||||
} else {
|
||||
console.log("handleUpdate-", e);
|
||||
fieldApi.handleChange(e);
|
||||
}
|
||||
});
|
||||
|
||||
//const required = field.isRequired();
|
||||
//const customFieldProps = { ...props, action, required };
|
||||
|
||||
if (field instanceof RelationField) {
|
||||
return (
|
||||
<EntityRelationalFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof JsonField) {
|
||||
return <EntityJsonFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
if (field instanceof JsonSchemaField) {
|
||||
return (
|
||||
<EntityJsonSchemaFormField
|
||||
fieldApi={fieldApi}
|
||||
field={field}
|
||||
data={data}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.tabIndex}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field instanceof EnumField) {
|
||||
return <EntityEnumFormField fieldApi={fieldApi} field={field} {...props} />;
|
||||
}
|
||||
|
||||
const fieldElement = field.getHtmlConfig().element;
|
||||
const fieldProps = field.getHtmlConfig().props as any;
|
||||
const Element = Formy.formElementFactory(fieldElement ?? "input", fieldProps);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Element
|
||||
{...fieldProps}
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityMediaFormField({
|
||||
formApi,
|
||||
field,
|
||||
entity,
|
||||
entityId,
|
||||
disabled
|
||||
}: {
|
||||
formApi: FormApi<any>;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
const client = useClient();
|
||||
const value = formApi.useStore((state) => {
|
||||
const val = state.values[field.name];
|
||||
if (!val || typeof val === "undefined") return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [val];
|
||||
});
|
||||
|
||||
const initialItems: FileState[] =
|
||||
value.length === 0
|
||||
? []
|
||||
: mediaItemsToFileStates(value, {
|
||||
baseUrl: client.baseUrl,
|
||||
overrides: { state: "uploaded" }
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent(() => {
|
||||
const api = client.media().api();
|
||||
return {
|
||||
url: api.getEntityUploadUrl(entity.name, entityId, field.name),
|
||||
headers: api.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file) => {
|
||||
client.__invalidate(entity.name, entityId);
|
||||
return await client.media().deleteFile(file);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel field={field} />
|
||||
<Dropzone
|
||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
initialItems={initialItems}
|
||||
maxItems={field.getMaxItems()}
|
||||
autoUpload
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityJsonFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: JsonField }) {
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
minHeight="100"
|
||||
/*required={field.isRequired()}*/
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
{/*<Formy.Textarea
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityEnumFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: EnumField }) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
fieldApi.handleChange(e.target.value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.Select
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate as any}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
>
|
||||
{!field.isRequired() && <option value="">- Select -</option>}
|
||||
{field.getOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Formy.Select>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
242
app/src/ui/modules/data/components/EntityTable.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import type { Entity, EntityData } from "data";
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbSelector,
|
||||
TbSquare,
|
||||
TbSquareCheckFilled
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
|
||||
export const Check = () => {
|
||||
const [checked, toggle] = useToggle([false, true]);
|
||||
const Icon = checked ? TbSquareCheckFilled : TbSquare;
|
||||
return (
|
||||
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type TableProps = {
|
||||
data: EntityData[];
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: EntityData) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
perPageOptions?: number[];
|
||||
sort?: { by?: string; dir?: "asc" | "desc" };
|
||||
onClickSort?: (name: string) => void;
|
||||
onClickPerPage?: (perPage: number) => void;
|
||||
classNames?: {
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const EntityTable: React.FC<TableProps> = ({
|
||||
data = [],
|
||||
entity,
|
||||
select,
|
||||
checkable,
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
perPageOptions,
|
||||
onClickPerPage,
|
||||
classNames
|
||||
}) => {
|
||||
select = select && select.length > 0 ? select : entity.getSelect();
|
||||
total = total || data.length;
|
||||
page = page || 1;
|
||||
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function handleSortClick(name: string) {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-y-scroll">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-background">
|
||||
<tr>
|
||||
{checkable && (
|
||||
<th align="center" className="w-[40px]">
|
||||
<Check />
|
||||
</th>
|
||||
)}
|
||||
{select.map((property, key) => {
|
||||
const field = getField(property)!;
|
||||
|
||||
return (
|
||||
<th key={key}>
|
||||
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
|
||||
<button
|
||||
type="button"
|
||||
className="link hover:bg-primary/5 pl-2.5 pr-1 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1"
|
||||
onClick={() => onClickSort?.(field.name)}
|
||||
>
|
||||
<span className="text-left text-nowrap whitespace-nowrap">
|
||||
{field.getLabel()}
|
||||
</span>
|
||||
<SortIndicator sort={sort} field={field.name} />
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, key) => {
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
data-border={key > 0}
|
||||
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
|
||||
onClick={() => onClickRow?.(row)}
|
||||
>
|
||||
{checkable && (
|
||||
<td align="center">
|
||||
<Check />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{Object.entries(row).map(([key, value], index) => {
|
||||
const field = getField(key);
|
||||
const _value = field?.getValue(value, "table");
|
||||
return (
|
||||
<td key={index}>
|
||||
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
|
||||
{value !== null && typeof value !== "undefined" ? (
|
||||
<span
|
||||
className={twMerge(classNames?.value, "line-clamp-2")}
|
||||
>
|
||||
{_value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-10 font-mono">null</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
|
||||
Per Page{" "}
|
||||
<Dropdown
|
||||
items={perPageOptions.map((perPage) => ({
|
||||
label: String(perPage),
|
||||
perPage
|
||||
}))}
|
||||
position="top-end"
|
||||
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
|
||||
>
|
||||
<Button>{perPage}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<TableProps, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
|
||||
return <TbArrowDown size={18} className="mt-[1px]" />;
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total }
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
};
|
||||
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
51
app/src/ui/modules/data/components/EntityTable2.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Entity, EntityData } from "data";
|
||||
import { CellValue, DataTable, type DataTableProps } from "ui/components/table/DataTable";
|
||||
|
||||
type EntityTableProps<Data extends EntityData = EntityData> = Omit<
|
||||
DataTableProps<Data>,
|
||||
"columns"
|
||||
> & {
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
};
|
||||
|
||||
export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
|
||||
const columns = select ?? entity.getSelect();
|
||||
|
||||
const fields = entity.getFields();
|
||||
|
||||
function getField(name: string) {
|
||||
return fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function renderHeader(column: string) {
|
||||
try {
|
||||
const field = getField(column)!;
|
||||
return field.getLabel();
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render header", { entity, select, ...props }, e);
|
||||
return column;
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue({ value, property }) {
|
||||
let _value: any = value;
|
||||
try {
|
||||
const field = getField(property)!;
|
||||
_value = field.getValue(value, "table");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
|
||||
}
|
||||
|
||||
return <CellValue value={_value} property={property} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
{...props}
|
||||
columns={columns}
|
||||
renderHeader={renderHeader}
|
||||
renderValue={renderValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user