public commit

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

36
app/src/ui/Admin.tsx Normal file
View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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]
};
}*/

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

View File

@@ -0,0 +1,4 @@
export { ClientProvider, useClient, useBaseUrl } from "./ClientProvider";
export { BkndProvider, useBknd } from "./BkndProvider";
export { useAuth } from "./schema/auth/use-auth";

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

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

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

View 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.");
}
}
}
};
}

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

View 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()
};
}

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

View 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] });
}
});
}
};
}
}
};
}

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

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

View File

@@ -0,0 +1,17 @@
import { useBaseUrl } from "../client/ClientProvider";
export function Context() {
const baseurl = useBaseUrl();
return (
<div>
{JSON.stringify(
{
baseurl
},
null,
2
)}
</div>
);
}

View 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)} />
));

View 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}
/>
);
}
);

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

View File

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

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

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

View 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>
</>
);
}

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

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

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

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

View 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>
);

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

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

View 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>
)
);

View 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>
);

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

View File

@@ -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}
/>
);
}

View File

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

View File

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

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);

View File

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

View File

@@ -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>
</>
);
}

View File

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

View File

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

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

View File

@@ -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 [];
}
}

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

View File

@@ -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}
/>
);
}

View File

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

View File

@@ -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} />
);
}

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
export const DropDown = () => {
return null;
};

View File

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

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

View 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
);

View 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}
/>
));
*/

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

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

View 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} />
);
}

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

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

View File

@@ -0,0 +1,2 @@
export * from "./EntitiesContainer";
export * from "./EntityContainer";

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

View File

@@ -0,0 +1,8 @@
import { useLayoutEffect } from "react";
export function useBrowserTitle(path: string[] = []) {
useLayoutEffect(() => {
const prefix = "BKND";
document.title = [prefix, ...path].join(" / ");
});
}

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

View 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
View 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";

View 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";

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

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

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

View File

@@ -0,0 +1 @@
export * as AppShell from "./AppShell";

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

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

View 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">;

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

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

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

View File

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

View File

@@ -0,0 +1,329 @@
import type { FieldApi, FormApi } from "@tanstack/react-form";
import {
type Entity,
type EntityData,
EnumField,
type Field,
JsonField,
JsonSchemaField,
RelationField
} from "data";
import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense } from "react";
import { useClient } from "ui/client";
import { JsonEditor } from "ui/components/code/JsonEditor";
import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "../../media/helper";
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
type EntityFormProps = {
entity: Entity;
entityId?: number;
data?: EntityData;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
fieldsDisabled: boolean;
Form: FormApi<any>;
className?: string;
action: "create" | "update";
};
export function EntityForm({
entity,
entityId,
handleSubmit,
fieldsDisabled,
Form,
data,
className,
action
}: EntityFormProps) {
const fields = entity.getFillableFields(action, true);
console.log("data", { data, fields });
return (
<form onSubmit={handleSubmit}>
<Form.Subscribe
selector={(state) => {
//console.log("state", state);
return [state.canSubmit, state.isValid, state.errors];
}}
children={([canSubmit, isValid, errors]) => {
//console.log("form:state", { canSubmit, isValid, errors });
return (
!isValid && (
<div className="flex flex-col dark:bg-red-950 bg-red-100 p-4">
<p>Form is invalid.</p>
{Array.isArray(errors) && (
<ul className="list-disc">
{errors.map((error, key) => (
<li className="ml-6" key={key}>
{error}
</li>
))}
</ul>
)}
</div>
)
);
}}
/>
<div className={className}>
{fields.map((field, key) => {
// @todo: tanstack form re-uses the state, causes issues navigating between entities w/ same fields
// media field needs to render outside of the form
// as its value is not stored in the form state
if (field instanceof MediaField) {
return (
<EntityMediaFormField
key={field.name + key}
entity={entity}
entityId={entityId}
formApi={Form}
field={field}
/>
);
}
if (!field.isFillable(action)) {
return;
}
const _key = `${entity.name}-${field.name}-${key}`;
return (
<Form.Field
key={_key}
name={field.name}
children={(props) => (
<EntityFormField
field={field}
fieldApi={props}
disabled={fieldsDisabled}
tabIndex={key + 1}
action={action}
data={data}
/>
)}
/>
);
})}
</div>
<div className="hidden">
<button type="submit" />
</div>
</form>
);
}
type EntityFormFieldProps<
T extends keyof JSX.IntrinsicElements = "input",
F extends Field = Field
> = ComponentProps<T> & {
fieldApi: FieldApi<any, any>;
field: F;
action: "create" | "update";
data?: EntityData;
};
type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
if (typeof e === "object" && "target" in e) {
console.log("handleUpdate", e.target.value);
fieldApi.handleChange(e.target.value);
} else {
console.log("handleUpdate-", e);
fieldApi.handleChange(e);
}
});
//const required = field.isRequired();
//const customFieldProps = { ...props, action, required };
if (field instanceof RelationField) {
return (
<EntityRelationalFormField
fieldApi={fieldApi}
field={field}
data={data}
disabled={props.disabled}
tabIndex={props.tabIndex}
/>
);
}
if (field instanceof JsonField) {
return <EntityJsonFormField fieldApi={fieldApi} field={field} {...props} />;
}
if (field instanceof JsonSchemaField) {
return (
<EntityJsonSchemaFormField
fieldApi={fieldApi}
field={field}
data={data}
disabled={props.disabled}
tabIndex={props.tabIndex}
{...props}
/>
);
}
if (field instanceof EnumField) {
return <EntityEnumFormField fieldApi={fieldApi} field={field} {...props} />;
}
const fieldElement = field.getHtmlConfig().element;
const fieldProps = field.getHtmlConfig().props as any;
const Element = Formy.formElementFactory(fieldElement ?? "input", fieldProps);
return (
<Formy.Group>
<FieldLabel htmlFor={fieldApi.name} field={field} />
<Element
{...fieldProps}
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
required={field.isRequired()}
{...props}
/>
</Formy.Group>
);
}
function EntityMediaFormField({
formApi,
field,
entity,
entityId,
disabled
}: {
formApi: FormApi<any>;
field: MediaField;
entity: Entity;
entityId?: number;
disabled?: boolean;
}) {
if (!entityId) return;
const client = useClient();
const value = formApi.useStore((state) => {
const val = state.values[field.name];
if (!val || typeof val === "undefined") return [];
if (Array.isArray(val)) return val;
return [val];
});
const initialItems: FileState[] =
value.length === 0
? []
: mediaItemsToFileStates(value, {
baseUrl: client.baseUrl,
overrides: { state: "uploaded" }
});
const getUploadInfo = useEvent(() => {
const api = client.media().api();
return {
url: api.getEntityUploadUrl(entity.name, entityId, field.name),
headers: api.getUploadHeaders(),
method: "POST"
};
});
const handleDelete = useEvent(async (file) => {
client.__invalidate(entity.name, entityId);
return await client.media().deleteFile(file);
});
return (
<Formy.Group>
<FieldLabel field={field} />
<Dropzone
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
initialItems={initialItems}
maxItems={field.getMaxItems()}
autoUpload
/>
</Formy.Group>
);
}
function EntityJsonFormField({
fieldApi,
field,
...props
}: { fieldApi: FieldApi<any, any>; field: JsonField }) {
const handleUpdate = useEvent((value: any) => {
fieldApi.handleChange(value);
});
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<Suspense>
<JsonEditor
id={fieldApi.name}
value={fieldApi.state.value}
onChange={handleUpdate}
onBlur={fieldApi.handleBlur}
minHeight="100"
/*required={field.isRequired()}*/
{...props}
/>
</Suspense>
{/*<Formy.Textarea
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
required={field.isRequired()}
{...props}
/>*/}
</Formy.Group>
);
}
function EntityEnumFormField({
fieldApi,
field,
...props
}: { fieldApi: FieldApi<any, any>; field: EnumField }) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value);
});
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<Formy.Select
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate as any}
required={field.isRequired()}
{...props}
>
{!field.isRequired() && <option value="">- Select -</option>}
{field.getOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Formy.Select>
</Formy.Group>
);
}

View File

@@ -0,0 +1,242 @@
import { useToggle } from "@mantine/hooks";
import type { Entity, EntityData } from "data";
import {
TbArrowDown,
TbArrowUp,
TbChevronLeft,
TbChevronRight,
TbChevronsLeft,
TbChevronsRight,
TbSelector,
TbSquare,
TbSquareCheckFilled
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { Dropdown } from "ui/components/overlay/Dropdown";
export const Check = () => {
const [checked, toggle] = useToggle([false, true]);
const Icon = checked ? TbSquareCheckFilled : TbSquare;
return (
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
<Icon size={18} />
</button>
);
};
type TableProps = {
data: EntityData[];
entity: Entity;
select?: string[];
checkable?: boolean;
onClickRow?: (row: EntityData) => void;
onClickPage?: (page: number) => void;
total?: number;
page?: number;
perPage?: number;
perPageOptions?: number[];
sort?: { by?: string; dir?: "asc" | "desc" };
onClickSort?: (name: string) => void;
onClickPerPage?: (perPage: number) => void;
classNames?: {
value?: string;
};
};
export const EntityTable: React.FC<TableProps> = ({
data = [],
entity,
select,
checkable,
onClickRow,
onClickPage,
onClickSort,
total,
sort,
page = 1,
perPage = 10,
perPageOptions,
onClickPerPage,
classNames
}) => {
select = select && select.length > 0 ? select : entity.getSelect();
total = total || data.length;
page = page || 1;
const pages = Math.max(Math.ceil(total / perPage), 1);
const fields = entity.getFields();
function getField(name: string) {
return fields.find((field) => field.name === name);
}
function handleSortClick(name: string) {}
return (
<div className="flex flex-col gap-3">
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-y-scroll">
<table className="w-full">
<thead className="sticky top-0 bg-background">
<tr>
{checkable && (
<th align="center" className="w-[40px]">
<Check />
</th>
)}
{select.map((property, key) => {
const field = getField(property)!;
return (
<th key={key}>
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
<button
type="button"
className="link hover:bg-primary/5 pl-2.5 pr-1 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1"
onClick={() => onClickSort?.(field.name)}
>
<span className="text-left text-nowrap whitespace-nowrap">
{field.getLabel()}
</span>
<SortIndicator sort={sort} field={field.name} />
</button>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{data.map((row, key) => {
return (
<tr
key={key}
data-border={key > 0}
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
onClick={() => onClickRow?.(row)}
>
{checkable && (
<td align="center">
<Check />
</td>
)}
{Object.entries(row).map(([key, value], index) => {
const field = getField(key);
const _value = field?.getValue(value, "table");
return (
<td key={index}>
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
{value !== null && typeof value !== "undefined" ? (
<span
className={twMerge(classNames?.value, "line-clamp-2")}
>
{_value}
</span>
) : (
<span className="opacity-10 font-mono">null</span>
)}
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-row items-center justify-between">
<div className="hidden md:flex text-primary/40">
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
</div>
<div className="flex flex-row gap-2 md:gap-10 items-center">
{perPageOptions && (
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
Per Page{" "}
<Dropdown
items={perPageOptions.map((perPage) => ({
label: String(perPage),
perPage
}))}
position="top-end"
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
>
<Button>{perPage}</Button>
</Dropdown>
</div>
)}
<div className="text-primary/40">
Page {page} of {pages}
</div>
{onClickPage && (
<div className="flex flex-row gap-1.5">
<TableNav current={page} total={pages} onClick={onClickPage} />
</div>
)}
</div>
</div>
</div>
);
};
const SortIndicator = ({
sort,
field
}: {
sort: Pick<TableProps, "sort">["sort"];
field: string;
}) => {
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
return <TbArrowDown size={18} className="mt-[1px]" />;
};
const TableDisplay = ({ perPage, page, items, total }) => {
if (total === 0) {
return <>No rows to show</>;
}
if (total === 1) {
return <>Showing 1 row</>;
}
return (
<>
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
</>
);
};
type TableNavProps = {
current: number;
total: number;
onClick?: (page: number) => void;
};
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
const navMap = [
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
{ value: total, Icon: TbChevronsRight, disabled: current === total }
] as const;
return navMap.map((nav, key) => (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
const page = nav.value;
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
));
};

View File

@@ -0,0 +1,51 @@
import type { Entity, EntityData } from "data";
import { CellValue, DataTable, type DataTableProps } from "ui/components/table/DataTable";
type EntityTableProps<Data extends EntityData = EntityData> = Omit<
DataTableProps<Data>,
"columns"
> & {
entity: Entity;
select?: string[];
};
export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
const columns = select ?? entity.getSelect();
const fields = entity.getFields();
function getField(name: string) {
return fields.find((field) => field.name === name);
}
function renderHeader(column: string) {
try {
const field = getField(column)!;
return field.getLabel();
} catch (e) {
console.warn("Couldn't render header", { entity, select, ...props }, e);
return column;
}
}
function renderValue({ value, property }) {
let _value: any = value;
try {
const field = getField(property)!;
_value = field.getValue(value, "table");
} catch (e) {
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
}
return <CellValue value={_value} property={property} />;
}
return (
<DataTable
{...props}
columns={columns}
renderHeader={renderHeader}
renderValue={renderValue}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More