mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/release/0.6' into refactor/optimize-ui-bundle-size
# Conflicts: # app/build.ts # app/package.json
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
@@ -10,6 +13,7 @@ type BkndContext = {
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
hasSecrets: boolean;
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
@@ -32,7 +36,9 @@ export function BkndProvider({
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [error, setError] = useState<boolean>();
|
||||
const errorShown = useRef<boolean>();
|
||||
const [local_version, set_local_version] = useState(0);
|
||||
const api = useApi();
|
||||
|
||||
async function reloadSchema() {
|
||||
@@ -49,15 +55,11 @@ export function BkndProvider({
|
||||
if (!res.ok) {
|
||||
if (errorShown.current) return;
|
||||
errorShown.current = true;
|
||||
/*notifications.show({
|
||||
title: "Failed to fetch schema",
|
||||
// @ts-ignore
|
||||
message: body.error,
|
||||
color: "red",
|
||||
position: "top-right",
|
||||
autoClose: false,
|
||||
withCloseButton: true
|
||||
});*/
|
||||
|
||||
setError(true);
|
||||
return;
|
||||
} else if (error) {
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const schema = res.ok
|
||||
@@ -80,6 +82,7 @@ export function BkndProvider({
|
||||
setSchema(schema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,9 +99,24 @@ export function BkndProvider({
|
||||
if (!fetched || !schema) return fallback;
|
||||
const app = new AppReduced(schema?.config as any);
|
||||
const actions = getSchemaActions({ api, setSchema, reloadSchema });
|
||||
const hasSecrets = withSecrets && !error;
|
||||
|
||||
return (
|
||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
|
||||
<BkndContext.Provider
|
||||
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }}
|
||||
key={local_version}
|
||||
>
|
||||
{error && (
|
||||
<Alert.Exception className="gap-2">
|
||||
<IconAlertHexagon />
|
||||
You attempted to load system configuration with secrets without having proper
|
||||
permission.
|
||||
<a href={schema.config.server.admin.basepath || "/"}>
|
||||
<Button variant="red">Reload</Button>
|
||||
</a>
|
||||
</Alert.Exception>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</BkndContext.Provider>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ export type ClientProviderProps = {
|
||||
};
|
||||
|
||||
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
|
||||
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||
@@ -31,6 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
|
||||
console.error("error .....", e);
|
||||
}
|
||||
|
||||
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Api } from "Api";
|
||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import { useApi } from "ui/client";
|
||||
|
||||
@@ -27,12 +27,19 @@ export const useApiQuery = <
|
||||
};
|
||||
};
|
||||
|
||||
export const useInvalidate = () => {
|
||||
export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||
const mutate = useSWRConfig().mutate;
|
||||
const api = useApi();
|
||||
|
||||
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => {
|
||||
if (!arg) return async () => mutate("");
|
||||
return mutate(typeof arg === "string" ? arg : arg(api).key());
|
||||
return async (arg?: string | ((api: Api) => FetchPromise<any> | ModuleApi<any>)) => {
|
||||
let key = "";
|
||||
if (typeof arg === "string") {
|
||||
key = arg;
|
||||
} else if (typeof arg === "function") {
|
||||
key = arg(api).key();
|
||||
}
|
||||
|
||||
if (options?.exact) return mutate(key);
|
||||
return mutate((k) => typeof k === "string" && k.startsWith(key));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DB, PrimaryFieldType } from "core";
|
||||
import { encodeSearch, objectTransform } from "core/utils";
|
||||
import type { EntityData, RepoQuery } from "data";
|
||||
import type { EntityData, RepoQuery, RepoQueryIn } from "data";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
@@ -22,15 +22,6 @@ export class UseEntityApiError<Payload = any> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function Test() {
|
||||
const { read } = useEntity("users");
|
||||
async () => {
|
||||
const data = await read();
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -49,7 +40,7 @@ export const useEntity = <
|
||||
}
|
||||
return res;
|
||||
},
|
||||
read: async (query: Partial<RepoQuery> = {}) => {
|
||||
read: async (query: RepoQueryIn = {}) => {
|
||||
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
||||
@@ -88,7 +79,7 @@ export function makeKey(
|
||||
api: ModuleApi,
|
||||
entity: string,
|
||||
id?: PrimaryFieldType,
|
||||
query?: Partial<RepoQuery>
|
||||
query?: RepoQueryIn
|
||||
) {
|
||||
return (
|
||||
"/" +
|
||||
@@ -105,11 +96,11 @@ export const useEntityQuery = <
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
query?: Partial<RepoQuery>,
|
||||
query?: RepoQueryIn,
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
const key = makeKey(api, entity, id, query);
|
||||
const key = makeKey(api, entity as string, id, query);
|
||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||
const fetcher = () => read(query);
|
||||
|
||||
@@ -121,7 +112,7 @@ export const useEntityQuery = <
|
||||
});
|
||||
|
||||
const mutateAll = async () => {
|
||||
const entityKey = makeKey(api, entity);
|
||||
const entityKey = makeKey(api, entity as string);
|
||||
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
||||
revalidate: true
|
||||
});
|
||||
@@ -167,7 +158,7 @@ export async function mutateEntityCache<
|
||||
return prev;
|
||||
}
|
||||
|
||||
const entityKey = makeKey(api, entity);
|
||||
const entityKey = makeKey(api, entity as string);
|
||||
|
||||
return mutate(
|
||||
(key) => typeof key === "string" && key.startsWith(entityKey),
|
||||
|
||||
@@ -73,23 +73,3 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
verify
|
||||
};
|
||||
};
|
||||
|
||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
const api = useApi(options?.baseUrl);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
//console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setData(res.body);
|
||||
}
|
||||
})();
|
||||
}, [options?.baseUrl]);
|
||||
|
||||
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "data/data-schema";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import type { TSchemaActions } from "ui/client/schema/actions";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
export function useBkndData() {
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
@@ -62,7 +63,13 @@ export function useBkndData() {
|
||||
}
|
||||
};
|
||||
const $data = {
|
||||
entity: (name: string) => entities[name]
|
||||
entity: (name: string) => entities[name],
|
||||
modals,
|
||||
system: (name: string) => ({
|
||||
any: entities[name]?.type === "system",
|
||||
users: name === config.auth.entity_name,
|
||||
media: name === config.media.entity_name
|
||||
})
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -75,6 +82,35 @@ export function useBkndData() {
|
||||
};
|
||||
}
|
||||
|
||||
const modals = {
|
||||
createAny: () => bkndModals.open(bkndModals.ids.dataCreate, {}),
|
||||
createEntity: () =>
|
||||
bkndModals.open(bkndModals.ids.dataCreate, {
|
||||
initialPath: ["entities", "entity"],
|
||||
initialState: { action: "entity" }
|
||||
}),
|
||||
createRelation: (rel: { source?: string; target?: string; type?: string }) =>
|
||||
bkndModals.open(bkndModals.ids.dataCreate, {
|
||||
initialPath: ["entities", "relation"],
|
||||
initialState: {
|
||||
action: "relation",
|
||||
relations: {
|
||||
create: [rel as any]
|
||||
}
|
||||
}
|
||||
}),
|
||||
createMedia: (entity?: string) =>
|
||||
bkndModals.open(bkndModals.ids.dataCreate, {
|
||||
initialPath: ["entities", "template-media"],
|
||||
initialState: {
|
||||
action: "template-media",
|
||||
initial: {
|
||||
entity
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
|
||||
return {
|
||||
add: async (name: string, field: TAppDataField) => {
|
||||
|
||||
@@ -4,15 +4,15 @@ 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"
|
||||
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-1.5",
|
||||
large: "px-4 py-3 rounded-md gap-2.5 text-lg"
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 15,
|
||||
default: 18,
|
||||
large: 22
|
||||
small: 12,
|
||||
default: 16,
|
||||
large: 20
|
||||
};
|
||||
|
||||
const styles = {
|
||||
|
||||
@@ -10,9 +10,9 @@ export type IconType =
|
||||
|
||||
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 }
|
||||
sm: { className: "p-0.5", size: 15 },
|
||||
md: { className: "p-1", size: 18 },
|
||||
lg: { className: "p-1.5", size: 22 }
|
||||
} as const;
|
||||
|
||||
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
|
||||
@@ -6,16 +6,27 @@ export type AlertProps = ComponentPropsWithoutRef<"div"> & {
|
||||
visible?: boolean;
|
||||
title?: string;
|
||||
message?: ReactNode | string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const Base: React.FC<AlertProps> = ({ visible = true, title, message, className, ...props }) =>
|
||||
const Base: React.FC<AlertProps> = ({
|
||||
visible = true,
|
||||
title,
|
||||
message,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) =>
|
||||
visible ? (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && <b className="mr-2">{title}:</b>}
|
||||
{message}
|
||||
{message || children}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Button } from "../buttons/Button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, type ButtonProps } from "../buttons/Button";
|
||||
|
||||
export type EmptyProps = {
|
||||
Icon?: any;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: () => void;
|
||||
primary?: ButtonProps;
|
||||
secondary?: ButtonProps;
|
||||
className?: string;
|
||||
};
|
||||
export const Empty: React.FC<EmptyProps> = ({
|
||||
Icon = undefined,
|
||||
title = undefined,
|
||||
description = "Check back later my friend.",
|
||||
buttonText,
|
||||
buttonOnClick
|
||||
primary,
|
||||
secondary,
|
||||
className
|
||||
}) => (
|
||||
<div className="flex flex-col h-full w-full justify-center items-center">
|
||||
<div className={twMerge("flex flex-col h-full w-full justify-center items-center", className)}>
|
||||
<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 className="mt-1.5 flex flex-row gap-2">
|
||||
{secondary && <Button variant="default" {...secondary} />}
|
||||
{primary && <Button variant="primary" {...primary} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { IconLockAccessOff } from "@tabler/icons-react";
|
||||
import { Empty, type EmptyProps } from "./Empty";
|
||||
|
||||
const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />;
|
||||
const NotAllowed = (props: Partial<EmptyProps>) => <Empty title="Not Allowed" {...props} />;
|
||||
const MissingPermission = ({
|
||||
what,
|
||||
...props
|
||||
}: Partial<EmptyProps> & {
|
||||
what?: string;
|
||||
}) => (
|
||||
<Empty
|
||||
Icon={IconLockAccessOff}
|
||||
title="Missing Permission"
|
||||
description={`You're not allowed to access ${what ?? "this"}.`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Message = {
|
||||
NotFound
|
||||
NotFound,
|
||||
NotAllowed,
|
||||
MissingPermission
|
||||
};
|
||||
|
||||
29
app/src/ui/components/form/Formy/BooleanInputMantine.tsx
Normal file
29
app/src/ui/components/form/Formy/BooleanInputMantine.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
|
||||
export const BooleanInputMantine = 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,11 +1,10 @@
|
||||
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";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
error,
|
||||
@@ -131,17 +130,6 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
}
|
||||
|
||||
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}
|
||||
@@ -153,7 +141,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
);*/
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
17
app/src/ui/components/form/Formy/index.ts
Normal file
17
app/src/ui/components/form/Formy/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BooleanInputMantine } from "./BooleanInputMantine";
|
||||
import { DateInput, Input, Textarea } from "./components";
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
case "date":
|
||||
return DateInput;
|
||||
case "boolean":
|
||||
return BooleanInputMantine;
|
||||
case "textarea":
|
||||
return Textarea;
|
||||
default:
|
||||
return Input;
|
||||
}
|
||||
};
|
||||
|
||||
export * from "./components";
|
||||
@@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & {
|
||||
schema: RJSFSchema | Schema;
|
||||
uiSchema?: any;
|
||||
direction?: "horizontal" | "vertical";
|
||||
onChange?: (value: any) => void;
|
||||
onChange?: (value: any, isValid: () => boolean) => void;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormRef = {
|
||||
formData: () => any;
|
||||
validateForm: () => boolean;
|
||||
silentValidate: () => boolean;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
@@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
|
||||
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);
|
||||
onChange?.(clean, () => isValid(clean));
|
||||
};
|
||||
|
||||
const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
formData: () => value,
|
||||
validateForm: () => formRef.current!.validateForm(),
|
||||
silentValidate: () => isValid(value),
|
||||
cancel: () => formRef.current!.reset()
|
||||
}),
|
||||
[value]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { Fragment, type ReactElement, cloneElement, useState } from "react";
|
||||
import { clampNumber } from "core/utils";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
useState
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export type DropdownItem =
|
||||
| (() => JSX.Element)
|
||||
@@ -14,26 +21,33 @@ export type DropdownItem =
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type DropdownClickableChild = ReactElement<{ onClick: () => void }>;
|
||||
export type DropdownProps = {
|
||||
className?: string;
|
||||
openEvent?: "onClick" | "onContextMenu";
|
||||
defaultOpen?: boolean;
|
||||
title?: string | ReactElement;
|
||||
dropdownWrapperProps?: Omit<ComponentPropsWithoutRef<"div">, "style">;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
hideOnEmpty?: boolean;
|
||||
items: (DropdownItem | undefined | boolean)[];
|
||||
itemsClassName?: string;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
children: DropdownClickableChild;
|
||||
onClickItem?: (item: DropdownItem) => void;
|
||||
renderItem?: (
|
||||
item: DropdownItem,
|
||||
props: { key: number; onClick: () => void }
|
||||
) => ReactElement<{ onClick: () => void }>;
|
||||
) => DropdownClickableChild;
|
||||
};
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
defaultOpen = false,
|
||||
position = "bottom-start",
|
||||
openEvent = "onClick",
|
||||
position: initialPosition = "bottom-start",
|
||||
dropdownWrapperProps,
|
||||
items,
|
||||
title,
|
||||
hideOnEmpty = true,
|
||||
onClickItem,
|
||||
renderItem,
|
||||
@@ -41,19 +55,58 @@ export function Dropdown({
|
||||
className
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
const menuItems = items.filter(Boolean) as DropdownItem[];
|
||||
const [_offset, _setOffset] = useState(0);
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
||||
);
|
||||
|
||||
const onClickHandler = openEvent === "onClick" ? toggle : undefined;
|
||||
const onContextMenuHandler = useEvent((e) => {
|
||||
if (openEvent !== "onContextMenu") return;
|
||||
e.preventDefault();
|
||||
|
||||
if (open) {
|
||||
toggle(0);
|
||||
setTimeout(() => {
|
||||
setPosition(initialPosition);
|
||||
_setOffset(0);
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
// minimal popper impl, get pos and boundaries
|
||||
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
|
||||
const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {};
|
||||
|
||||
// only if boundaries gien
|
||||
if (left > 0 && right > 0) {
|
||||
const safe = clampNumber(x, left, right);
|
||||
// if pos less than half, go left
|
||||
if (x < (left + right) / 2) {
|
||||
setPosition("bottom-start");
|
||||
_setOffset(safe);
|
||||
} else {
|
||||
setPosition("bottom-end");
|
||||
_setOffset(right - safe);
|
||||
}
|
||||
} else {
|
||||
setPosition(initialPosition);
|
||||
_setOffset(0);
|
||||
}
|
||||
|
||||
toggle();
|
||||
});
|
||||
|
||||
const offset = 4;
|
||||
const dropdownStyle = {
|
||||
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
||||
"bottom-end": { right: 0, top: "100%", marginTop: offset },
|
||||
"bottom-start": { top: "100%", left: _offset, marginTop: offset },
|
||||
"bottom-end": { right: _offset, top: "100%", marginTop: offset },
|
||||
"top-start": { bottom: "100%", marginBottom: offset },
|
||||
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
|
||||
"top-end": { bottom: "100%", right: _offset, marginBottom: offset }
|
||||
}[position];
|
||||
|
||||
const internalOnClickItem = useEvent((item) => {
|
||||
@@ -94,13 +147,25 @@ export function Dropdown({
|
||||
));
|
||||
|
||||
return (
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
<div
|
||||
role="dropdown"
|
||||
className={twMerge("relative flex", className)}
|
||||
ref={clickoutsideRef}
|
||||
onContextMenu={onContextMenuHandler}
|
||||
>
|
||||
{cloneElement(children as any, { onClick: onClickHandler })}
|
||||
{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"
|
||||
{...dropdownWrapperProps}
|
||||
className={twMerge(
|
||||
"absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full",
|
||||
dropdownWrapperProps?.className
|
||||
)}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
|
||||
)}
|
||||
{menuItems.map((item, i) =>
|
||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
export type TStepsProps = {
|
||||
children: any;
|
||||
initialPath?: string[];
|
||||
initialState?: any;
|
||||
lastBack?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -19,13 +20,14 @@ type TStepContext<T = any> = {
|
||||
stepBack: () => void;
|
||||
close: () => void;
|
||||
state: T;
|
||||
path: string[];
|
||||
setState: Dispatch<SetStateAction<T>>;
|
||||
};
|
||||
|
||||
const StepContext = createContext<TStepContext>(undefined as any);
|
||||
|
||||
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>({});
|
||||
export function Steps({ children, initialPath = [], initialState = {}, lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>(initialState);
|
||||
const [path, setPath] = useState<string[]>(initialPath);
|
||||
const steps: any[] = Children.toArray(children).filter(
|
||||
(child: any) => child.props.disabled !== true
|
||||
@@ -46,7 +48,7 @@ export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
|
||||
const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0];
|
||||
|
||||
return (
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}>
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, path, setState, close: lastBack! }}>
|
||||
{current}
|
||||
</StepContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { type TSchema, Type, Value } from "core/utils";
|
||||
import { Form, type Validator } from "json-schema-form-react";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy";
|
||||
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
className?: string;
|
||||
@@ -86,7 +86,7 @@ export function AuthForm({
|
||||
schema={schema}
|
||||
validator={validator}
|
||||
validationMode="change"
|
||||
className={twMerge("flex flex-col gap-3 w-full", className)}
|
||||
className={clsx("flex flex-col gap-3 w-full", className)}
|
||||
>
|
||||
{({ errors, submitting }) => (
|
||||
<>
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||
import { useAuthStrategies } from "../hooks/use-auth";
|
||||
import { AuthForm } from "./AuthForm";
|
||||
|
||||
export type AuthScreenProps = {
|
||||
method?: "POST" | "GET";
|
||||
@@ -18,13 +16,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
|
||||
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
|
||||
{!loading && (
|
||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||
{typeof logo !== "undefined" ? (
|
||||
logo
|
||||
) : (
|
||||
<Link href={"/"} className="link">
|
||||
<Logo scale={0.25} />
|
||||
</Link>
|
||||
)}
|
||||
{logo ? logo : null}
|
||||
{typeof intro !== "undefined" ? (
|
||||
intro
|
||||
) : (
|
||||
9
app/src/ui/elements/auth/index.ts
Normal file
9
app/src/ui/elements/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AuthForm } from "./AuthForm";
|
||||
import { AuthScreen } from "./AuthScreen";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export const Auth = {
|
||||
Screen: AuthScreen,
|
||||
Form: AuthForm,
|
||||
SocialLink: SocialLink
|
||||
};
|
||||
23
app/src/ui/elements/hooks/use-auth.ts
Normal file
23
app/src/ui/elements/hooks/use-auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
|
||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
const api = useApi(options?.baseUrl);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
//console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setData(res.body);
|
||||
}
|
||||
})();
|
||||
}, [options?.baseUrl]);
|
||||
|
||||
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Auth } from "ui/modules/auth/index";
|
||||
export { Auth } from "./auth";
|
||||
export * from "./media";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { RepoQuery } from "data";
|
||||
import type { RepoQuery, RepoQueryIn } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId } from "react";
|
||||
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import {
|
||||
Dropzone,
|
||||
type DropzoneProps,
|
||||
type DropzoneRenderProps,
|
||||
type FileState
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
@@ -20,7 +15,7 @@ export type DropzoneContainerProps = {
|
||||
id: number;
|
||||
field: string;
|
||||
};
|
||||
query?: Partial<RepoQuery>;
|
||||
query?: RepoQueryIn;
|
||||
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||
Partial<DropzoneProps>;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* MIT License (2020 Roland Groza)
|
||||
*/
|
||||
|
||||
import { MIME_TYPES } from "media";
|
||||
import { guess } from "media/storage/mime-types-tiny";
|
||||
|
||||
const FILES_TO_IGNORE = [
|
||||
// Thumbnail cache files for macOS and Windows
|
||||
@@ -47,10 +47,8 @@ function withMimeType(file: FileWithPath) {
|
||||
console.log("withMimeType", name, hasExtension);
|
||||
|
||||
if (hasExtension && !file.type) {
|
||||
const ext = name.split(".").pop()!.toLowerCase();
|
||||
const type = MIME_TYPES.get(ext);
|
||||
|
||||
console.log("withMimeType:in", ext, type);
|
||||
const type = guess(name);
|
||||
console.log("guessed", type);
|
||||
|
||||
if (type) {
|
||||
Object.defineProperty(file, "type", {
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { FileState } from "./components/dropzone/Dropzone";
|
||||
import type { FileState } from "./Dropzone";
|
||||
|
||||
export function mediaItemToFileState(
|
||||
item: MediaFieldSchema,
|
||||
15
app/src/ui/elements/media/index.ts
Normal file
15
app/src/ui/elements/media/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PreviewWrapperMemoized } from "./Dropzone";
|
||||
import { DropzoneContainer } from "./DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "./Dropzone";
|
||||
export type { DropzoneContainerProps } from "./DropzoneContainer";
|
||||
3
app/src/ui/elements/mocks/tailwind-merge.ts
Normal file
3
app/src/ui/elements/mocks/tailwind-merge.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function twMerge(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
32
app/src/ui/hooks/use-effect.ts
Normal file
32
app/src/ui/hooks/use-effect.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useEffectOnce(effect: () => void | (() => void | undefined), deps: any[]): void {
|
||||
const hasRunRef = useRef(false);
|
||||
const savedDepsRef = useRef<any[] | undefined>(deps);
|
||||
|
||||
useEffect(() => {
|
||||
const depsChanged = !hasRunRef.current || !areDepsEqual(savedDepsRef.current, deps);
|
||||
|
||||
if (depsChanged) {
|
||||
hasRunRef.current = true;
|
||||
savedDepsRef.current = deps;
|
||||
return effect();
|
||||
}
|
||||
}, [deps]);
|
||||
}
|
||||
|
||||
function areDepsEqual(prevDeps: any[] | undefined, nextDeps: any[]): boolean {
|
||||
if (prevDeps && prevDeps.length === 0 && nextDeps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!prevDeps && nextDeps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!prevDeps || !nextDeps || prevDeps.length !== nextDeps.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return prevDeps.every((dep, index) => Object.is(dep, nextDeps[index]));
|
||||
}
|
||||
@@ -191,7 +191,7 @@ export const SidebarLink = <E extends React.ElementType = "a">({
|
||||
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",
|
||||
"cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 focus:bg-primary/5 link",
|
||||
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { encodeSearch } from "core/utils";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useRouter } from "wouter";
|
||||
import { useBknd } from "../client/BkndProvider";
|
||||
|
||||
export const routes = {
|
||||
@@ -55,6 +55,7 @@ export function withAbsolute(url: string) {
|
||||
|
||||
export function useNavigate() {
|
||||
const [location, navigate] = useLocation();
|
||||
const router = useRouter();
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
return [
|
||||
@@ -69,6 +70,7 @@ export function useNavigate() {
|
||||
transition?: boolean;
|
||||
}
|
||||
| { reload: true }
|
||||
| { target: string }
|
||||
) => {
|
||||
const wrap = (fn: () => void) => {
|
||||
fn();
|
||||
@@ -81,9 +83,15 @@ export function useNavigate() {
|
||||
};
|
||||
|
||||
wrap(() => {
|
||||
if (options && "reload" in options) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
if (options) {
|
||||
if ("reload" in options) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
} else if ("target" in options) {
|
||||
const _url = window.location.origin + basepath + router.base + url;
|
||||
window.open(_url, options.target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||
|
||||
@@ -1,211 +1,74 @@
|
||||
@import "./components/form/json-schema/styles.css";
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
@import "@mantine/core/styles.css";
|
||||
@import "@mantine/notifications/styles.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.fixed,
|
||||
html.fixed body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
.bknd-admin {
|
||||
--color-primary: 9 9 11; /* zinc-950 */
|
||||
--color-background: 250 250 250; /* zinc-50 */
|
||||
--color-muted: 228 228 231; /* ? */
|
||||
--color-darkest: 0 0 0; /* black */
|
||||
--color-lightest: 255 255 255; /* white */
|
||||
|
||||
&.dark {
|
||||
--color-primary: 250 250 250; /* zinc-50 */
|
||||
--color-background: 30 31 34;
|
||||
--color-muted: 47 47 52;
|
||||
--color-darkest: 255 255 255; /* white */
|
||||
--color-lightest: 24 24 27; /* black */
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
--mantine-color-body: rgb(250 250 250);
|
||||
}
|
||||
@mixin dark {
|
||||
--mantine-color-body: rgb(9 9 11);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
#bknd-admin {
|
||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||
|
||||
::selection {
|
||||
@apply bg-muted;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
input {
|
||||
&::selection {
|
||||
@apply bg-primary/15;
|
||||
}
|
||||
}
|
||||
input {
|
||||
&::selection {
|
||||
@apply bg-primary/15;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body,
|
||||
#bknd-admin {
|
||||
@apply flex flex-1 flex-col h-dvh w-dvw;
|
||||
@apply flex flex-1 flex-col h-dvh w-dvw;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.link {
|
||||
@apply transition-colors active:translate-y-px;
|
||||
}
|
||||
.link {
|
||||
@apply transition-colors active:translate-y-px;
|
||||
}
|
||||
|
||||
.img-responsive {
|
||||
@apply max-h-full w-auto;
|
||||
}
|
||||
.img-responsive {
|
||||
@apply max-h-full w-auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* debug classes
|
||||
*/
|
||||
.bordered-red {
|
||||
@apply border-2 border-red-500;
|
||||
}
|
||||
/**
|
||||
* debug classes
|
||||
*/
|
||||
.bordered-red {
|
||||
@apply border-2 border-red-500;
|
||||
}
|
||||
|
||||
.bordered-green {
|
||||
@apply border-2 border-green-500;
|
||||
}
|
||||
.bordered-green {
|
||||
@apply border-2 border-green-500;
|
||||
}
|
||||
|
||||
.bordered-blue {
|
||||
@apply border-2 border-blue-500;
|
||||
}
|
||||
.bordered-blue {
|
||||
@apply border-2 border-blue-500;
|
||||
}
|
||||
|
||||
.bordered-violet {
|
||||
@apply border-2 border-violet-500;
|
||||
}
|
||||
.bordered-violet {
|
||||
@apply border-2 border-violet-500;
|
||||
}
|
||||
|
||||
.bordered-yellow {
|
||||
@apply border-2 border-yellow-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.app-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.app-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div:first-child {
|
||||
display: block !important;
|
||||
min-width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* hide calendar icon on inputs */
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* cm */
|
||||
.cm-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeInAnimation 200ms ease;
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[readonly]::placeholder,
|
||||
input[disabled]::placeholder {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.react-flow__pane,
|
||||
.react-flow__renderer,
|
||||
.react-flow__node,
|
||||
.react-flow__edge {
|
||||
cursor: inherit !important;
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
.react-flow .react-flow__edge path,
|
||||
.react-flow__connectionline path {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mantine-TextInput-wrapper input {
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
background: transparent;
|
||||
}
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.flex-animate {
|
||||
transition: flex-grow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.flex-initial {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.flex-open {
|
||||
flex: 1 1 0;
|
||||
.bordered-yellow {
|
||||
@apply border-2 border-yellow-500;
|
||||
}
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
.bknd-admin {
|
||||
/* Chrome, Edge, and Safari */
|
||||
& *::-webkit-scrollbar {
|
||||
@apply w-1;
|
||||
&:horizontal {
|
||||
@apply h-px;
|
||||
}
|
||||
}
|
||||
/* Chrome, Edge, and Safari */
|
||||
& *::-webkit-scrollbar {
|
||||
@apply w-1;
|
||||
&:horizontal {
|
||||
@apply h-px;
|
||||
}
|
||||
}
|
||||
|
||||
& *::-webkit-scrollbar-track {
|
||||
@apply bg-transparent w-1;
|
||||
}
|
||||
& *::-webkit-scrollbar-track {
|
||||
@apply bg-transparent w-1;
|
||||
}
|
||||
|
||||
& *::-webkit-scrollbar-thumb {
|
||||
@apply bg-primary/25;
|
||||
}
|
||||
& *::-webkit-scrollbar-thumb {
|
||||
@apply bg-primary/25;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import Admin from "./Admin";
|
||||
import "./main.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
22
app/src/ui/modals/debug/OverlayModal.tsx
Normal file
22
app/src/ui/modals/debug/OverlayModal.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function OverlayModal({
|
||||
context,
|
||||
id,
|
||||
innerProps: { content }
|
||||
}: ContextModalProps<{ content?: ReactNode }>) {
|
||||
return content;
|
||||
}
|
||||
|
||||
OverlayModal.defaultTitle = undefined;
|
||||
OverlayModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
classNames: {
|
||||
size: "md",
|
||||
root: "bknd-admin",
|
||||
content: "text-center justify-center",
|
||||
title: "font-bold !text-md",
|
||||
body: "py-3 px-5 gap-4 flex flex-col"
|
||||
}
|
||||
};
|
||||
@@ -7,21 +7,31 @@ import {
|
||||
} from "ui/components/form/json-schema";
|
||||
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
type Props = JsonSchemaFormProps & {
|
||||
onSubmit?: (data: any) => void | Promise<void>;
|
||||
autoCloseAfterSubmit?: boolean;
|
||||
onSubmit?: (
|
||||
data: any,
|
||||
context: {
|
||||
close: () => void;
|
||||
}
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function SchemaFormModal({
|
||||
context,
|
||||
id,
|
||||
innerProps: { schema, uiSchema, onSubmit }
|
||||
innerProps: { schema, uiSchema, onSubmit, autoCloseAfterSubmit }
|
||||
}: ContextModalProps<Props>) {
|
||||
const [valid, setValid] = useState(false);
|
||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const was_submitted = useRef(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
function handleChange(data) {
|
||||
const valid = formRef.current?.validateForm() ?? false;
|
||||
function handleChange(data, isValid) {
|
||||
const valid = isValid();
|
||||
console.log("Data changed", data, valid);
|
||||
setValid(valid);
|
||||
}
|
||||
@@ -30,29 +40,45 @@ export function SchemaFormModal({
|
||||
context.closeModal(id);
|
||||
}
|
||||
|
||||
async function handleClickAdd() {
|
||||
await onSubmit?.(formRef.current?.formData());
|
||||
handleClose();
|
||||
async function handleSubmit() {
|
||||
was_submitted.current = true;
|
||||
if (!formRef.current?.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
await onSubmit?.(formRef.current?.formData(), {
|
||||
close: handleClose,
|
||||
setError
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (autoCloseAfterSubmit !== false) {
|
||||
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>
|
||||
<>
|
||||
{error && <Alert.Exception message={error} />}
|
||||
<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={handleSubmit}
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={!valid || submitting}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +89,7 @@ SchemaFormModal.modalProps = {
|
||||
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",
|
||||
title: "!font-bold !text-md",
|
||||
body: "!p-0"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { ModalsProvider, modals as mantineModals } from "@mantine/modals";
|
||||
import { transformObject } from "core/utils";
|
||||
import { modals as $modals, ModalsProvider, closeModal, openContextModal } from "@mantine/modals";
|
||||
import type { ComponentProps } from "react";
|
||||
import { OverlayModal } from "ui/modals/debug/OverlayModal";
|
||||
import { CreateModal } from "ui/modules/data/components/schema/create-modal/CreateModal";
|
||||
import { DebugModal } from "./debug/DebugModal";
|
||||
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
||||
import { TestModal } from "./debug/TestModal";
|
||||
@@ -9,7 +10,9 @@ import { TestModal } from "./debug/TestModal";
|
||||
const modals = {
|
||||
test: TestModal,
|
||||
debug: DebugModal,
|
||||
form: SchemaFormModal
|
||||
form: SchemaFormModal,
|
||||
overlay: OverlayModal,
|
||||
dataCreate: CreateModal
|
||||
};
|
||||
|
||||
declare module "@mantine/modals" {
|
||||
@@ -33,25 +36,29 @@ function open<Modal extends keyof typeof modals>(
|
||||
) {
|
||||
const title = _title ?? modals[modal].defaultTitle ?? undefined;
|
||||
const cmpModalProps = modals[modal].modalProps ?? {};
|
||||
return mantineModals.openContextModal({
|
||||
const props = {
|
||||
title,
|
||||
...modalProps,
|
||||
...cmpModalProps,
|
||||
modal,
|
||||
innerProps
|
||||
});
|
||||
};
|
||||
openContextModal(props);
|
||||
return {
|
||||
close: () => close(modal),
|
||||
closeAll: $modals.closeAll
|
||||
};
|
||||
}
|
||||
|
||||
function close<Modal extends keyof typeof modals>(modal: Modal) {
|
||||
return mantineModals.close(modal);
|
||||
return closeModal(modal);
|
||||
}
|
||||
|
||||
export const bkndModals = {
|
||||
ids: transformObject(modals, (key) => key) as unknown as Record<
|
||||
keyof typeof modals,
|
||||
keyof typeof modals
|
||||
>,
|
||||
ids: Object.fromEntries(Object.keys(modals).map((key) => [key, key])) as {
|
||||
[K in keyof typeof modals]: K;
|
||||
},
|
||||
open,
|
||||
close,
|
||||
closeAll: mantineModals.closeAll
|
||||
closeAll: $modals.closeAll
|
||||
};
|
||||
|
||||
53
app/src/ui/modules/auth/hooks/use-create-user-modal.ts
Normal file
53
app/src/ui/modules/auth/hooks/use-create-user-modal.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
export function useCreateUserModal() {
|
||||
const api = useApi();
|
||||
const { config } = useBkndAuth();
|
||||
const invalidate = useInvalidate();
|
||||
const [navigate] = useNavigate();
|
||||
|
||||
const open = async () => {
|
||||
const loading = bkndModals.open("overlay", {
|
||||
content: "Loading..."
|
||||
});
|
||||
|
||||
const schema = await api.auth.actionSchema("password", "create");
|
||||
loading.closeAll(); // currently can't close by id...
|
||||
|
||||
bkndModals.open(
|
||||
"form",
|
||||
{
|
||||
schema,
|
||||
uiSchema: {
|
||||
password: {
|
||||
"ui:widget": "password"
|
||||
}
|
||||
},
|
||||
autoCloseAfterSubmit: false,
|
||||
onSubmit: async (data, ctx) => {
|
||||
console.log("submitted:", data, ctx);
|
||||
const res = await api.auth.action("password", "create", data);
|
||||
console.log(res);
|
||||
if (res.ok) {
|
||||
// invalidate all data
|
||||
invalidate();
|
||||
navigate(routes.data.entity.edit(config.entity_name, res.data.id));
|
||||
ctx.close();
|
||||
} else if ("error" in res) {
|
||||
ctx.setError(res.error);
|
||||
} else {
|
||||
ctx.setError("Unknown error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Create User"
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return { open };
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||
|
||||
export const Auth = {
|
||||
Screen: AuthScreen,
|
||||
Form: AuthForm,
|
||||
SocialLink: SocialLink
|
||||
};
|
||||
@@ -34,7 +34,11 @@ export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
|
||||
const field = getField(property)!;
|
||||
_value = field.getValue(value, "table");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
|
||||
console.warn(
|
||||
"Couldn't render value",
|
||||
{ value, property, entity, select, columns, ...props },
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return <CellValue value={_value} property={property} />;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TbToggleLeft
|
||||
} from "react-icons/tb";
|
||||
|
||||
type TFieldSpec = {
|
||||
export type TFieldSpec = {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { EntityTable } from "../EntityTable";
|
||||
@@ -82,7 +83,9 @@ export function EntityRelationalFormField({
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.Label htmlFor={fieldApi.name}>
|
||||
{field.getLabel({ fallback: false }) ?? entity.label}
|
||||
</Formy.Label>
|
||||
<div
|
||||
data-disabled={fetching || disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
@@ -152,9 +155,11 @@ export function EntityRelationalFormField({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button IconLeft={TbEye} onClick={handleViewItem} size="small">
|
||||
View
|
||||
</Button>
|
||||
<Link to={routes.data.entity.edit(entity.name, _value.id as any)}>
|
||||
<Button IconLeft={TbEye} size="small">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<div className="pl-2">- Select -</div>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { type Static, StringEnum, StringIdentifier, Type, transformObject } from "core/utils";
|
||||
import { FieldClassMap } from "data";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { type Static, StringEnum, StringIdentifier, Type } from "core/utils";
|
||||
import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema";
|
||||
import { omit } from "lodash-es";
|
||||
import { forwardRef, useState } from "react";
|
||||
import {
|
||||
Modal2,
|
||||
type Modal2Ref,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalTitle
|
||||
} from "ui/components/modal/Modal2";
|
||||
import { useState } from "react";
|
||||
import { type Modal2Ref, ModalBody, ModalFooter, ModalTitle } from "ui/components/modal/Modal2";
|
||||
import { Step, Steps, useStepContext } from "ui/components/steps/Steps";
|
||||
import { StepCreate } from "ui/modules/data/components/schema/create-modal/step.create";
|
||||
import { StepEntity } from "./step.entity";
|
||||
@@ -45,6 +39,7 @@ export type TFieldCreate = Static<typeof createFieldSchema>;
|
||||
const createModalSchema = Type.Object(
|
||||
{
|
||||
action: schemaAction,
|
||||
initial: Type.Optional(Type.Any()),
|
||||
entities: Type.Optional(
|
||||
Type.Object({
|
||||
create: Type.Optional(Type.Array(entitySchema))
|
||||
@@ -67,48 +62,59 @@ const createModalSchema = Type.Object(
|
||||
);
|
||||
export type TCreateModalSchema = Static<typeof createModalSchema>;
|
||||
|
||||
export const CreateModal = forwardRef<CreateModalRef>(function CreateModal(props, ref) {
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
export function CreateModal({
|
||||
context,
|
||||
id,
|
||||
innerProps: { initialPath = [], initialState }
|
||||
}: ContextModalProps<{ initialPath?: string[]; initialState?: TCreateModalSchema }>) {
|
||||
const [path, setPath] = useState<string[]>(initialPath);
|
||||
console.log("...", initialPath, initialState);
|
||||
|
||||
function close() {
|
||||
// @ts-ignore
|
||||
ref?.current?.close();
|
||||
context.closeModal(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal2 ref={ref}>
|
||||
<Steps path={path} lastBack={close}>
|
||||
<Step id="select">
|
||||
<ModalTitle path={["Create New"]} onClose={close} />
|
||||
<StepSelect />
|
||||
</Step>
|
||||
<Step id="entity" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
|
||||
<StepEntity />
|
||||
</Step>
|
||||
<Step id="entity-fields" path={["action", "entity"]}>
|
||||
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
|
||||
<StepEntityFields />
|
||||
</Step>
|
||||
<Step id="relation" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
|
||||
<StepRelation />
|
||||
</Step>
|
||||
<Step id="create" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
|
||||
<StepCreate />
|
||||
</Step>
|
||||
<Steps path={path} lastBack={close} initialPath={initialPath} initialState={initialState}>
|
||||
<Step id="select">
|
||||
<ModalTitle path={["Create New"]} onClose={close} />
|
||||
<StepSelect />
|
||||
</Step>
|
||||
<Step id="entity" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
|
||||
<StepEntity />
|
||||
</Step>
|
||||
<Step id="entity-fields" path={["action", "entity"]}>
|
||||
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
|
||||
<StepEntityFields />
|
||||
</Step>
|
||||
<Step id="relation" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
|
||||
<StepRelation />
|
||||
</Step>
|
||||
<Step id="create" path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
|
||||
<StepCreate />
|
||||
</Step>
|
||||
|
||||
{/* Templates */}
|
||||
{Templates.map(([Component, meta]) => (
|
||||
<Step key={meta.id} id={meta.id} path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
|
||||
<Component />
|
||||
</Step>
|
||||
))}
|
||||
</Steps>
|
||||
</Modal2>
|
||||
{/* Templates */}
|
||||
{Templates.map(([Component, meta]) => (
|
||||
<Step key={meta.id} id={meta.id} path={["action"]}>
|
||||
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
|
||||
<Component />
|
||||
</Step>
|
||||
))}
|
||||
</Steps>
|
||||
);
|
||||
});
|
||||
}
|
||||
CreateModal.defaultTitle = undefined;
|
||||
CreateModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
size: "xl",
|
||||
padding: 0,
|
||||
classNames: {
|
||||
root: "bknd-admin"
|
||||
}
|
||||
} satisfies Partial<ModalProps>;
|
||||
|
||||
export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema };
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbCirclesRelation, TbSettings } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { IconButton, type IconType } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
@@ -26,6 +26,7 @@ export function StepCreate() {
|
||||
const [states, setStates] = useState<(boolean | string)[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const $data = useBkndData();
|
||||
const b = useBknd();
|
||||
|
||||
const items: ActionItem[] = [];
|
||||
if (state.entities?.create) {
|
||||
@@ -74,6 +75,10 @@ export function StepCreate() {
|
||||
try {
|
||||
const res = await item.run();
|
||||
setStates((prev) => [...prev, res]);
|
||||
if (res !== true) {
|
||||
// make sure to break out
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setStates((prev) => [...prev, (e as any).message]);
|
||||
}
|
||||
@@ -90,7 +95,8 @@ export function StepCreate() {
|
||||
states.every((s) => s === true)
|
||||
);
|
||||
if (items.length === states.length && states.every((s) => s === true)) {
|
||||
close();
|
||||
b.actions.reload().then(close);
|
||||
//close();
|
||||
} else {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -144,12 +150,14 @@ const SummaryItem: React.FC<SummaryItemProps> = ({
|
||||
}) => {
|
||||
const [expanded, handlers] = useDisclosure(initialExpanded);
|
||||
const error = typeof state !== "undefined" && state !== true;
|
||||
const done = state === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background mb-2",
|
||||
error && "bg-red-500/20"
|
||||
error && "bg-red-500/20",
|
||||
done && "bg-green-500/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-4 px-2 py-2 items-center">
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
registerCustomTypeboxKinds
|
||||
} from "core/utils";
|
||||
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { type ReactNode, startTransition, useEffect } from "react";
|
||||
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
||||
import { TbRefresh } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import { useStepContext } from "ui/components/steps/Steps";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal";
|
||||
|
||||
// @todo: check if this could become an issue
|
||||
@@ -63,7 +66,7 @@ type ComponentCtx<T extends FieldValues = FieldValues> = {
|
||||
export function StepRelation() {
|
||||
const { config } = useBknd();
|
||||
const entities = config.data.entities;
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { nextStep, stepBack, state, path, setState } = useStepContext<TCreateModalSchema>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -93,6 +96,22 @@ export function StepRelation() {
|
||||
}
|
||||
}
|
||||
|
||||
const flip = useEvent(() => {
|
||||
const { source, target } = data;
|
||||
if (source && target) {
|
||||
setValue("source", target);
|
||||
setValue("target", source);
|
||||
} else {
|
||||
if (source) {
|
||||
setValue("target", source);
|
||||
setValue("source", null as any);
|
||||
} else {
|
||||
setValue("source", target);
|
||||
setValue("target", null as any);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(handleNext)}>
|
||||
@@ -109,14 +128,23 @@ export function StepRelation() {
|
||||
disabled: data.target === name
|
||||
}))}
|
||||
/>
|
||||
<MantineSelect
|
||||
control={control}
|
||||
name="type"
|
||||
onChange={() => setValue("config", {})}
|
||||
label="Relation Type"
|
||||
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<MantineSelect
|
||||
control={control}
|
||||
name="type"
|
||||
onChange={() => setValue("config", {})}
|
||||
label="Relation Type"
|
||||
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{data.type && (
|
||||
<div className="flex justify-center mt-1">
|
||||
<Button size="small" IconLeft={TbRefresh} onClick={flip}>
|
||||
Flip entities
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MantineSelect
|
||||
control={control}
|
||||
allowDeselect={false}
|
||||
@@ -146,7 +174,7 @@ export function StepRelation() {
|
||||
onClick: handleNext
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
debug={{ state, data }}
|
||||
debug={{ state, path, data }}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import Templates from "./templates/register";
|
||||
|
||||
export function StepSelect() {
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { nextStep, stepBack, state, path, setState } = useStepContext<TCreateModalSchema>();
|
||||
const selected = state.action ?? null;
|
||||
|
||||
function handleSelect(action: TSchemaAction) {
|
||||
@@ -74,6 +74,7 @@ export function StepSelect() {
|
||||
}}
|
||||
prev={{ onClick: stepBack }}
|
||||
prevLabel="Cancel"
|
||||
debug={{ state, path }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import type { MediaFieldConfig } from "media/MediaField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
|
||||
@@ -31,18 +32,19 @@ const schema = Type.Object({
|
||||
type TCreateModalMediaSchema = Static<typeof schema>;
|
||||
|
||||
export function TemplateMediaComponent() {
|
||||
const { stepBack, setState, state, nextStep } = useStepContext<TCreateModalSchema>();
|
||||
const { stepBack, setState, state, path, nextStep } = useStepContext<TCreateModalSchema>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isValid },
|
||||
setValue,
|
||||
formState: { isValid, errors },
|
||||
watch,
|
||||
control
|
||||
} = useForm({
|
||||
mode: "onChange",
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: Default(schema, {}) as TCreateModalMediaSchema
|
||||
defaultValues: Default(schema, state.initial ?? {}) as TCreateModalMediaSchema
|
||||
});
|
||||
const [forbidden, setForbidden] = useState<boolean>(false);
|
||||
|
||||
const { config } = useBknd();
|
||||
const media_enabled = config.media.enabled ?? false;
|
||||
@@ -51,13 +53,16 @@ export function TemplateMediaComponent() {
|
||||
name !== media_entity ? entity : undefined
|
||||
);
|
||||
const data = watch();
|
||||
const forbidden_field_names = Object.keys(config.data.entities?.[data.entity]?.fields ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
setForbidden(forbidden_field_names.includes(data.name));
|
||||
}, [forbidden_field_names, data.name]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (isValid) {
|
||||
console.log("data", data);
|
||||
if (isValid && !forbidden) {
|
||||
const { field, relation } = convert(media_entity, data);
|
||||
|
||||
console.log("state", { field, relation });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fields: { create: [field] },
|
||||
@@ -120,6 +125,13 @@ export function TemplateMediaComponent() {
|
||||
data.entity ? data.entity : "the entity"
|
||||
}.`}
|
||||
{...register("name")}
|
||||
error={
|
||||
errors.name?.message
|
||||
? errors.name?.message
|
||||
: forbidden
|
||||
? `Property "${data.name}" already exists on entity ${data.entity}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/*<p>step template media</p>
|
||||
@@ -129,12 +141,12 @@ export function TemplateMediaComponent() {
|
||||
<ModalFooter
|
||||
next={{
|
||||
type: "submit",
|
||||
disabled: !isValid || !media_enabled
|
||||
disabled: !isValid || !media_enabled || forbidden
|
||||
}}
|
||||
prev={{
|
||||
onClick: stepBack
|
||||
}}
|
||||
debug={{ state, data }}
|
||||
debug={{ state, path, data }}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -29,7 +29,11 @@ export function useEntityForm({
|
||||
onSubmitAsync: async ({ value }): Promise<any> => {
|
||||
try {
|
||||
//console.log("validating", value, entity.isValidData(value, action));
|
||||
entity.isValidData(value, action, true);
|
||||
entity.isValidData(value, action, {
|
||||
explain: true,
|
||||
// unknown will later be removed in getChangeSet
|
||||
ignoreUnknown: true
|
||||
});
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
//console.log("---validation error", e);
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { Auth } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||
|
||||
export function AuthLogin() {
|
||||
useBrowserTitle(["Login"]);
|
||||
return <AuthScreen action="login" />;
|
||||
return (
|
||||
<Auth.Screen
|
||||
action="login"
|
||||
logo={
|
||||
<Link href={"/"} className="link">
|
||||
<Logo scale={0.25} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useRef } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
@@ -12,7 +12,11 @@ import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
|
||||
|
||||
export function AuthRolesEdit(props) {
|
||||
useBknd({ withSecrets: true });
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Roles & Permissions" />;
|
||||
}
|
||||
|
||||
return <AuthRolesEditInternal {...props} />;
|
||||
}
|
||||
|
||||
@@ -28,14 +32,9 @@ function AuthRolesEditInternal({ params }) {
|
||||
if (!formRef.current?.isValid()) return;
|
||||
const data = formRef.current?.getData();
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
|
||||
/*notifications.show({
|
||||
id: `role-${roleName}-update`,
|
||||
position: "top-right",
|
||||
title: success ? "Update success" : "Update failed",
|
||||
message: success ? "Role updated successfully" : "Failed to update role",
|
||||
color: !success ? "red" : undefined
|
||||
});*/
|
||||
if (success) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
|
||||
@@ -90,9 +90,16 @@ const renderValue = ({ value, property }) => {
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
const max = 3;
|
||||
let permissions = value || [];
|
||||
const count = permissions.length;
|
||||
if (count > max) {
|
||||
permissions = [...permissions.slice(0, max), `+${count - max}`];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{[...(value || [])].map((p, i) => (
|
||||
{permissions.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { SegmentedControl } from "@mantine/core";
|
||||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlignJustified,
|
||||
IconCirclesRelation,
|
||||
IconDatabase,
|
||||
IconExternalLink,
|
||||
IconPhoto,
|
||||
IconPlus,
|
||||
IconSettings
|
||||
} from "@tabler/icons-react";
|
||||
import type { Entity, TEntityType } from "data";
|
||||
import { TbDatabasePlus } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Dropdown, type DropdownClickableChild } from "ui/components/overlay/Dropdown";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
@@ -11,9 +22,7 @@ import { routes, useNavigate } from "ui/lib/routes";
|
||||
|
||||
export function DataRoot({ children }) {
|
||||
// @todo: settings routes should be centralized
|
||||
const {
|
||||
app: { entities }
|
||||
} = useBknd();
|
||||
const { entities, $data } = useBkndData();
|
||||
const entityList: Record<TEntityType, Entity[]> = {
|
||||
regular: [],
|
||||
generated: [],
|
||||
@@ -22,7 +31,7 @@ export function DataRoot({ children }) {
|
||||
const [navigate] = useNavigate();
|
||||
const context = window.location.href.match(/\/schema/) ? "schema" : "data";
|
||||
|
||||
for (const entity of entities) {
|
||||
for (const entity of Object.values(entities)) {
|
||||
entityList[entity.getType()].push(entity);
|
||||
}
|
||||
|
||||
@@ -52,14 +61,19 @@ export function DataRoot({ children }) {
|
||||
<AppShell.Sidebar>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ value: "data", label: "Data" },
|
||||
{ value: "schema", label: "Schema" }
|
||||
]}
|
||||
value={context}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ value: "data", label: "Data" },
|
||||
{ value: "schema", label: "Schema" }
|
||||
]}
|
||||
value={context}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<Tooltip label="New Entity">
|
||||
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Entities
|
||||
@@ -70,7 +84,7 @@ export function DataRoot({ children }) {
|
||||
<SearchInput placeholder="Search entities" />
|
||||
</div>*/}
|
||||
|
||||
<EntityLinkList entities={entityList.regular} context={context} />
|
||||
<EntityLinkList entities={entityList.regular} context={context} suggestCreate />
|
||||
<EntityLinkList entities={entityList.system} context={context} title="System" />
|
||||
<EntityLinkList
|
||||
entities={entityList.generated}
|
||||
@@ -88,9 +102,22 @@ export function DataRoot({ children }) {
|
||||
const EntityLinkList = ({
|
||||
entities,
|
||||
title,
|
||||
context
|
||||
}: { entities: Entity[]; title?: string; context: "data" | "schema" }) => {
|
||||
if (entities.length === 0) return null;
|
||||
context,
|
||||
suggestCreate = false
|
||||
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
|
||||
const { $data } = useBkndData();
|
||||
if (entities.length === 0) {
|
||||
return suggestCreate ? (
|
||||
<Empty
|
||||
className="py-10"
|
||||
description="Create your first entity to get started."
|
||||
secondary={{
|
||||
children: "Create entity",
|
||||
onClick: () => $data.modals.createEntity()
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -107,21 +134,98 @@ const EntityLinkList = ({
|
||||
? routes.data.entity.list(entity.name)
|
||||
: routes.data.schema.entity(entity.name);
|
||||
return (
|
||||
<AppShell.SidebarLink key={entity.name} as={Link} href={href}>
|
||||
{entity.label}
|
||||
</AppShell.SidebarLink>
|
||||
<EntityContextMenu key={entity.name} entity={entity}>
|
||||
<AppShell.SidebarLink as={Link} href={href}>
|
||||
{entity.label}
|
||||
</AppShell.SidebarLink>
|
||||
</EntityContextMenu>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const EntityContextMenu = ({
|
||||
entity,
|
||||
children,
|
||||
enabled = true
|
||||
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
|
||||
if (!enabled) return children;
|
||||
const [navigate] = useNavigate();
|
||||
const { $data } = useBkndData();
|
||||
|
||||
// get href from children (single item)
|
||||
const href = (children as any).props.href;
|
||||
const separator = () => <div className="h-px my-1 w-full bg-primary/5" />;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="flex flex-col w-full"
|
||||
dropdownWrapperProps={{
|
||||
className: "min-w-fit"
|
||||
}}
|
||||
title={entity.label + " Actions"}
|
||||
items={[
|
||||
href && {
|
||||
icon: IconExternalLink,
|
||||
label: "Open in tab",
|
||||
onClick: () => navigate(href, { target: "_blank" })
|
||||
},
|
||||
separator,
|
||||
!$data.system(entity.name).any && {
|
||||
icon: IconPlus,
|
||||
label: "Create new",
|
||||
onClick: () => navigate(routes.data.entity.create(entity.name))
|
||||
},
|
||||
{
|
||||
icon: IconDatabase,
|
||||
label: "List entries",
|
||||
onClick: () => navigate(routes.data.entity.list(entity.name))
|
||||
},
|
||||
separator,
|
||||
{
|
||||
icon: IconAlignJustified,
|
||||
label: "Manage fields",
|
||||
onClick: () => navigate(routes.data.schema.entity(entity.name))
|
||||
},
|
||||
{
|
||||
icon: IconCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () =>
|
||||
$data.modals.createRelation({
|
||||
target: entity.name,
|
||||
type: "n:1"
|
||||
})
|
||||
},
|
||||
!$data.system(entity.name).media && {
|
||||
icon: IconPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name)
|
||||
},
|
||||
separator,
|
||||
{
|
||||
icon: IconSettings,
|
||||
label: "Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true
|
||||
})
|
||||
}
|
||||
]}
|
||||
openEvent="onContextMenu"
|
||||
position="bottom-start"
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export function DataEmpty() {
|
||||
useBrowserTitle(["Data"]);
|
||||
const [navigate] = useNavigate();
|
||||
const { $data } = useBkndData();
|
||||
|
||||
function handleButtonClick() {
|
||||
//navigate(routes.settings.path(["data", "entities"]), { absolute: true });
|
||||
navigate(routes.data.schema.root());
|
||||
}
|
||||
|
||||
@@ -130,8 +234,14 @@ export function DataEmpty() {
|
||||
Icon={IconDatabase}
|
||||
title="No entity selected"
|
||||
description="Please select an entity from the left sidebar or create a new one to continue."
|
||||
buttonText="Go to schema"
|
||||
buttonOnClick={handleButtonClick}
|
||||
secondary={{
|
||||
children: "Go to schema",
|
||||
onClick: handleButtonClick
|
||||
}}
|
||||
primary={{
|
||||
children: "Create entity",
|
||||
onClick: $data.modals.createEntity
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useApiQuery, useEntityQuery } from "ui/client";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
@@ -18,7 +19,11 @@ import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||
|
||||
export function DataEntityUpdate({ params }) {
|
||||
const { $data, relations } = useBkndData();
|
||||
const entity = $data.entity(params.entity as string)!;
|
||||
const entity = $data.entity(params.entity as string);
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
const entityId = Number.parseInt(params.id as string);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [navigate] = useNavigate();
|
||||
@@ -36,7 +41,9 @@ export function DataEntityUpdate({ params }) {
|
||||
with: local_relation_refs
|
||||
},
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
keepPreviousData: false,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false
|
||||
}
|
||||
);
|
||||
|
||||
@@ -81,8 +88,15 @@ export function DataEntityUpdate({ params }) {
|
||||
onSubmitted
|
||||
});
|
||||
|
||||
const makeKey = (key: string | number = "") =>
|
||||
`${params.entity.name}_${entityId}_${String(key)}`;
|
||||
if (!data && !$q.isLoading) {
|
||||
return (
|
||||
<Message.NotFound
|
||||
description={`Entity "${params.entity}" with ID "${entityId}" doesn't exist.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const makeKey = (key: string | number = "") => `${entity.name}_${entityId}_${String(key)}`;
|
||||
|
||||
const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
|
||||
|
||||
@@ -234,7 +248,7 @@ function EntityDetailInner({
|
||||
const other = relation.other(entity);
|
||||
const [navigate] = useNavigate();
|
||||
|
||||
const search: Partial<RepoQuery> = {
|
||||
const search = {
|
||||
select: other.entity.getSelect(undefined, "table"),
|
||||
limit: 10,
|
||||
offset: 0
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Type } from "core/utils";
|
||||
import type { EntityData } from "data";
|
||||
import { useState } from "react";
|
||||
import { useEntityMutate } from "ui/client";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useSearch } from "ui/hooks/use-search";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
@@ -13,8 +14,14 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
|
||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||
|
||||
export function DataEntityCreate({ params }) {
|
||||
const { app } = useBknd();
|
||||
const entity = app.entity(params.entity as string)!;
|
||||
const { $data } = useBkndData();
|
||||
const entity = $data.entity(params.entity as string);
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
} else if (entity.type !== "regular") {
|
||||
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
|
||||
}
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useBrowserTitle(["Data", entity.label, "Create"]);
|
||||
|
||||
@@ -43,7 +50,7 @@ export function DataEntityCreate({ params }) {
|
||||
|
||||
const { Form, handleSubmit } = useEntityForm({
|
||||
action: "create",
|
||||
entity,
|
||||
entity: entity,
|
||||
initialData: search.value,
|
||||
onSubmitted
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Type } from "core/utils";
|
||||
import { querySchema } from "data";
|
||||
import { type Entity, querySchema } from "data";
|
||||
import { Fragment } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApi, useApiQuery } from "ui/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -11,6 +13,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useSearch } from "ui/hooks/use-search";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
|
||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
@@ -29,23 +32,28 @@ const PER_PAGE_OPTIONS = [5, 10, 25];
|
||||
|
||||
export function DataEntityList({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
const entity = $data.entity(params.entity as string)!;
|
||||
const entity = $data.entity(params.entity as string);
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
||||
const [navigate] = useNavigate();
|
||||
const search = useSearch(searchSchema, {
|
||||
select: entity?.getSelect(undefined, "table") ?? [],
|
||||
sort: entity?.getDefaultSort()
|
||||
select: undefined,
|
||||
sort: undefined
|
||||
});
|
||||
|
||||
const $q = useApiQuery(
|
||||
(api) =>
|
||||
api.data.readMany(entity.name, {
|
||||
api.data.readMany(entity?.name as any, {
|
||||
select: search.value.select,
|
||||
limit: search.value.perPage,
|
||||
offset: (search.value.page - 1) * search.value.perPage,
|
||||
sort: search.value.sort
|
||||
sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`
|
||||
}),
|
||||
{
|
||||
enabled: !!entity,
|
||||
revalidateOnFocus: true,
|
||||
keepPreviousData: true
|
||||
}
|
||||
@@ -75,14 +83,10 @@ export function DataEntityList({ params }) {
|
||||
search.set("perPage", perPage);
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
const isUpdating = $q.isLoading && $q.isValidating;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment key={entity.name}>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
@@ -90,6 +94,14 @@ export function DataEntityList({ params }) {
|
||||
items={[
|
||||
{
|
||||
label: "Settings",
|
||||
onClick: () => navigate(routes.data.schema.entity(entity.name))
|
||||
},
|
||||
{
|
||||
label: "Data Schema",
|
||||
onClick: () => navigate(routes.data.schema.root())
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true
|
||||
@@ -100,14 +112,7 @@ export function DataEntityList({ params }) {
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate(routes.data.entity.create(entity.name));
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
<EntityCreateButton entity={entity} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -126,7 +131,7 @@ export function DataEntityList({ params }) {
|
||||
<EntityTable2
|
||||
data={data ?? null}
|
||||
entity={entity}
|
||||
/*select={search.value.select}*/
|
||||
select={search.value.select}
|
||||
onClickRow={handleClickRow}
|
||||
page={search.value.page}
|
||||
sort={search.value.sort}
|
||||
@@ -140,6 +145,40 @@ export function DataEntityList({ params }) {
|
||||
</div>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityCreateButton({ entity }: { entity: Entity }) {
|
||||
const b = useBknd();
|
||||
const createUserModal = useCreateUserModal();
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
if (!entity) return null;
|
||||
if (entity.type !== "regular") {
|
||||
const system = {
|
||||
users: b.app.config.auth.entity_name,
|
||||
media: b.app.config.media.entity_name
|
||||
};
|
||||
if (system.users === entity.name) {
|
||||
return (
|
||||
<Button onClick={createUserModal.open} variant="primary">
|
||||
New User
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate(routes.data.entity.create(entity.name));
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,23 +8,32 @@ import { isDebug } from "core";
|
||||
import type { Entity } from "data";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useRef, useState } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import {
|
||||
TbCirclesRelation,
|
||||
TbDatabasePlus,
|
||||
TbDots,
|
||||
TbPhoto,
|
||||
TbPlus,
|
||||
TbSitemap
|
||||
} from "react-icons/tb";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||
|
||||
export function DataSchemaEntity({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
const [value, setValue] = useState("fields");
|
||||
const fieldsRef = useRef<EntityFieldsFormRef>(null);
|
||||
|
||||
function toggle(value) {
|
||||
return () => setValue(value);
|
||||
@@ -32,6 +41,9 @@ export function DataSchemaEntity({ params }) {
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
const entity = $data.entity(params.entity as string)!;
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,7 +53,14 @@ export function DataSchemaEntity({ params }) {
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Settings",
|
||||
label: "Data",
|
||||
onClick: () =>
|
||||
navigate(routes.data.root() + routes.data.entity.list(entity.name), {
|
||||
absolute: true
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true
|
||||
@@ -52,14 +71,46 @@ export function DataSchemaEntity({ params }) {
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () =>
|
||||
$data.modals.createRelation({
|
||||
target: entity.name,
|
||||
type: "n:1"
|
||||
})
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name)
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity()
|
||||
}
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<Link to="/" className="invisible md:visible">
|
||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-col h-full" key={entity.name}>
|
||||
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
|
||||
@@ -74,12 +125,11 @@ export function DataSchemaEntity({ params }) {
|
||||
<Empty
|
||||
title="Relations"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
buttonText="Advanced Settings"
|
||||
buttonOnClick={() =>
|
||||
navigate(routes.settings.path(["data", "relations"]), {
|
||||
absolute: true
|
||||
})
|
||||
}
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "relations"]), { absolute: true })
|
||||
}}
|
||||
/>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
@@ -91,12 +141,13 @@ export function DataSchemaEntity({ params }) {
|
||||
<Empty
|
||||
title="Indices"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
buttonText="Advanced Settings"
|
||||
buttonOnClick={() =>
|
||||
navigate(routes.settings.path(["data", "indices"]), {
|
||||
absolute: true
|
||||
})
|
||||
}
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "indices"]), {
|
||||
absolute: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</div>
|
||||
@@ -111,7 +162,7 @@ const Fields = ({
|
||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updates, setUpdates] = useState(0);
|
||||
const { actions } = useBkndData();
|
||||
const { actions, $data } = useBkndData();
|
||||
const [res, setRes] = useState<any>();
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
async function handleUpdate() {
|
||||
@@ -144,7 +195,30 @@ const Fields = ({
|
||||
{submitting && (
|
||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||
)}
|
||||
<EntityFieldsForm fields={initialFields} ref={ref} key={String(updates)} sortable />
|
||||
<EntityFieldsForm
|
||||
fields={initialFields}
|
||||
ref={ref}
|
||||
key={String(updates)}
|
||||
sortable
|
||||
additionalFieldTypes={fieldSpecs
|
||||
.filter((f) => ["relation", "media"].includes(f.type))
|
||||
.map((i) => ({
|
||||
...i,
|
||||
onClick: () => {
|
||||
switch (i.type) {
|
||||
case "relation":
|
||||
$data.modals.createRelation({
|
||||
target: entity.name,
|
||||
type: "n:1"
|
||||
});
|
||||
break;
|
||||
case "media":
|
||||
$data.modals.createMedia(entity.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
|
||||
{isDebug() && (
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Suspense, lazy, useRef } from "react";
|
||||
import {
|
||||
CreateModal,
|
||||
type CreateModalRef
|
||||
} from "ui/modules/data/components/schema/create-modal/CreateModal";
|
||||
import { Button } from "../../components/buttons/Button";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const DataSchemaCanvas = lazy(() =>
|
||||
import("ui/modules/data/components/canvas/DataSchemaCanvas").then((m) => ({
|
||||
@@ -13,18 +10,12 @@ const DataSchemaCanvas = lazy(() =>
|
||||
);
|
||||
|
||||
export function DataSchemaIndex() {
|
||||
const createModalRef = useRef<CreateModalRef>(null);
|
||||
|
||||
const { $data } = useBkndData();
|
||||
return (
|
||||
<>
|
||||
<CreateModal ref={createModalRef} />
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => createModalRef.current?.open()}
|
||||
>
|
||||
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
||||
Create new
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitc
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||
|
||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||
@@ -45,7 +45,6 @@ type TFieldsFormSchema = Static<typeof schema>;
|
||||
|
||||
const fieldTypes = Object.keys(fieldsSchemaObject);
|
||||
const defaultType = fieldTypes[0];
|
||||
const blank_field = { name: "", field: { type: defaultType, config: {} } } as TFieldSchema;
|
||||
const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"];
|
||||
|
||||
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
|
||||
@@ -53,6 +52,13 @@ function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
|
||||
return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps);
|
||||
}
|
||||
|
||||
export type EntityFieldsFormProps = {
|
||||
fields: TAppDataEntityFields;
|
||||
onChange?: (formData: TAppDataEntityFields) => void;
|
||||
sortable?: boolean;
|
||||
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
||||
};
|
||||
|
||||
export type EntityFieldsFormRef = {
|
||||
getValues: () => TFieldsFormSchema;
|
||||
getData: () => TAppDataEntityFields;
|
||||
@@ -60,146 +66,156 @@ export type EntityFieldsFormRef = {
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const EntityFieldsForm = forwardRef<
|
||||
EntityFieldsFormRef,
|
||||
{
|
||||
fields: TAppDataEntityFields;
|
||||
onChange?: (formData: TAppDataEntityFields) => void;
|
||||
sortable?: boolean;
|
||||
}
|
||||
>(function EntityFieldsForm({ fields: _fields, sortable, ...props }, ref) {
|
||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||
name,
|
||||
field
|
||||
}));
|
||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
|
||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||
name,
|
||||
field
|
||||
}));
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isValid, errors },
|
||||
getValues,
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
setError,
|
||||
reset
|
||||
} = useForm({
|
||||
mode: "all",
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: {
|
||||
fields: entityFields
|
||||
} as TFieldsFormSchema
|
||||
});
|
||||
const { fields, append, remove, move } = useFieldArray({
|
||||
control,
|
||||
name: "fields"
|
||||
});
|
||||
const {
|
||||
control,
|
||||
formState: { isValid, errors },
|
||||
getValues,
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
setError,
|
||||
reset
|
||||
} = useForm({
|
||||
mode: "all",
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: {
|
||||
fields: entityFields
|
||||
} as TFieldsFormSchema
|
||||
});
|
||||
const { fields, append, remove, move } = useFieldArray({
|
||||
control,
|
||||
name: "fields"
|
||||
});
|
||||
|
||||
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
|
||||
return Object.fromEntries(
|
||||
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props?.onChange) {
|
||||
console.log("----set");
|
||||
watch((data: any) => {
|
||||
console.log("---calling");
|
||||
props?.onChange?.(toCleanValues(data));
|
||||
});
|
||||
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
|
||||
return Object.fromEntries(
|
||||
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset,
|
||||
getValues: () => getValues(),
|
||||
getData: () => {
|
||||
return toCleanValues(getValues());
|
||||
},
|
||||
isValid: () => isValid
|
||||
}));
|
||||
|
||||
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
|
||||
const newField = {
|
||||
name: "",
|
||||
new: true,
|
||||
field: {
|
||||
type: _type,
|
||||
config: {}
|
||||
useEffect(() => {
|
||||
if (props?.onChange) {
|
||||
console.log("----set");
|
||||
watch((data: any) => {
|
||||
console.log("---calling");
|
||||
props?.onChange?.(toCleanValues(data));
|
||||
});
|
||||
}
|
||||
};
|
||||
append(newField);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formProps = {
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
control,
|
||||
setError
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sortable ? (
|
||||
<SortableList
|
||||
data={fields}
|
||||
key={fields.length}
|
||||
onReordered={move}
|
||||
extractId={(item) => item.id}
|
||||
disableIndices={[0]}
|
||||
renderItem={({ dnd, ...props }, index) => (
|
||||
<EntityFieldMemo
|
||||
key={props.id}
|
||||
field={props as any}
|
||||
index={index}
|
||||
form={formProps}
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
dnd={dnd}
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset,
|
||||
getValues: () => getValues(),
|
||||
getData: () => {
|
||||
return toCleanValues(getValues());
|
||||
},
|
||||
isValid: () => isValid
|
||||
}));
|
||||
|
||||
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
|
||||
const newField = {
|
||||
name: "",
|
||||
new: true,
|
||||
field: {
|
||||
type: _type,
|
||||
config: {}
|
||||
}
|
||||
};
|
||||
append(newField);
|
||||
}
|
||||
|
||||
const formProps = {
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
control,
|
||||
setError
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sortable ? (
|
||||
<SortableList
|
||||
data={fields}
|
||||
key={fields.length}
|
||||
onReordered={move}
|
||||
extractId={(item) => item.id}
|
||||
disableIndices={[0]}
|
||||
renderItem={({ dnd, ...props }, index) => (
|
||||
<EntityFieldMemo
|
||||
key={props.id}
|
||||
field={props as any}
|
||||
index={index}
|
||||
form={formProps}
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
dnd={dnd}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<EntityField
|
||||
key={field.id}
|
||||
field={field as any}
|
||||
index={index}
|
||||
form={formProps}
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
className="flex flex-col w-full"
|
||||
target={({ toggle }) => (
|
||||
<SelectType
|
||||
additionalFieldTypes={additionalFieldTypes}
|
||||
onSelected={toggle}
|
||||
onSelect={(type) => {
|
||||
handleAppend(type as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<EntityField
|
||||
key={field.id}
|
||||
field={field as any}
|
||||
index={index}
|
||||
form={formProps}
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
className="flex flex-col w-full"
|
||||
target={({ toggle }) => (
|
||||
<SelectType
|
||||
onSelect={(type) => {
|
||||
handleAppend(type as any);
|
||||
toggle();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button className="justify-center">Add Field</Button>
|
||||
</Popover>
|
||||
>
|
||||
<Button className="justify-center">Add Field</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
|
||||
const types = fieldSpecs.filter((s) => s.addable !== false);
|
||||
const SelectType = ({
|
||||
onSelect,
|
||||
additionalFieldTypes = [],
|
||||
onSelected
|
||||
}: {
|
||||
onSelect: (type: string) => void;
|
||||
additionalFieldTypes?: (TFieldSpec & { onClick?: () => void })[];
|
||||
onSelected?: () => void;
|
||||
}) => {
|
||||
const types: (TFieldSpec & { onClick?: () => void })[] = fieldSpecs.filter(
|
||||
(s) => s.addable !== false
|
||||
);
|
||||
|
||||
if (additionalFieldTypes) {
|
||||
types.push(...additionalFieldTypes);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2 justify-center flex-wrap">
|
||||
@@ -208,7 +224,14 @@ const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
|
||||
key={type.type}
|
||||
IconLeft={type.icon}
|
||||
variant="ghost"
|
||||
onClick={() => onSelect(type.type)}
|
||||
onClick={() => {
|
||||
if (type.addable !== false) {
|
||||
onSelect(type.type);
|
||||
} else {
|
||||
type.onClick?.();
|
||||
}
|
||||
onSelected?.();
|
||||
}}
|
||||
>
|
||||
{type.label}
|
||||
</Button>
|
||||
|
||||
@@ -55,8 +55,10 @@ export function FlowsEmpty() {
|
||||
title="No flow selected"
|
||||
description="Please select a flow from the left sidebar or create a new one
|
||||
to continue."
|
||||
buttonText="Create Flow"
|
||||
buttonOnClick={() => navigate(app.getSettingsPath(["flows"]))}
|
||||
primary={{
|
||||
children: "Create Flow",
|
||||
onClick: () => navigate(app.getSettingsPath(["flows"]))
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -20,8 +20,10 @@ export function MediaRoot({ children }) {
|
||||
Icon={IconPhoto}
|
||||
title="Media not enabled"
|
||||
description="Please enable media in the settings to continue."
|
||||
buttonText="Manage Settings"
|
||||
buttonOnClick={() => navigate(app.getSettingsPath(["media"]))}
|
||||
primary={{
|
||||
children: "Manage Settings",
|
||||
onClick: () => navigate(app.getSettingsPath(["media"]))
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { IconHome } from "@tabler/icons-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "ui/client";
|
||||
import { useEffectOnce } from "ui/hooks/use-effect";
|
||||
import { Empty } from "../components/display/Empty";
|
||||
import { useBrowserTitle } from "../hooks/use-browser-title";
|
||||
import * as AppShell from "../layouts/AppShell/AppShell";
|
||||
import { useNavigate } from "../lib/routes";
|
||||
|
||||
export const Root = ({ children }) => {
|
||||
const { verify } = useAuth();
|
||||
const { verify, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
useEffectOnce(() => {
|
||||
verify();
|
||||
}, []);
|
||||
}, [user?.id]);
|
||||
|
||||
return (
|
||||
<AppShell.Root>
|
||||
|
||||
@@ -149,8 +149,9 @@ export function Setting<Schema extends TObject = any>({
|
||||
console.log("save:success", success);
|
||||
if (success) {
|
||||
if (options?.reloadOnSave) {
|
||||
window.location.reload();
|
||||
//await actions.reload();
|
||||
//window.location.reload();
|
||||
await actions.reload();
|
||||
setSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setSubmitting(false);
|
||||
@@ -175,7 +176,10 @@ export function Setting<Schema extends TObject = any>({
|
||||
<Empty
|
||||
title="Not found"
|
||||
description={`Configuration at path ${path.join(".")} doesn't exist.`}
|
||||
buttonText="Go back"
|
||||
primary={{
|
||||
children: "Go back",
|
||||
onClick: () => goBack()
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { IconSettings } from "@tabler/icons-react";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { Route, Switch } from "wouter";
|
||||
import { Setting } from "./components/Setting";
|
||||
import { Setting, type SettingProps } from "./components/Setting";
|
||||
import { AuthSettings } from "./routes/auth.settings";
|
||||
import { DataSettings } from "./routes/data.settings";
|
||||
import { FlowsSettings } from "./routes/flows.settings";
|
||||
@@ -44,7 +45,9 @@ function SettingsSidebar() {
|
||||
}
|
||||
|
||||
export default function SettingsRoutes() {
|
||||
useBknd({ withSecrets: true });
|
||||
const b = useBknd({ withSecrets: true });
|
||||
if (!b.hasSecrets) return <Message.MissingPermission what="the settings" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSidebar />
|
||||
@@ -117,13 +120,24 @@ const SettingRoutesRoutes = () => {
|
||||
<ServerSettings schema={schema.server} config={config.server} />
|
||||
<DataSettings schema={schema.data} config={config.data} />
|
||||
<AuthSettings schema={schema.auth} config={config.auth} />
|
||||
<FallbackRoutes module="media" schema={schema} config={config} uiSchema={uiSchema.media} />
|
||||
<FallbackRoutes
|
||||
module="media"
|
||||
schema={schema}
|
||||
config={config}
|
||||
uiSchema={uiSchema.media}
|
||||
options={{ reloadOnSave: true }}
|
||||
/>
|
||||
<FlowsSettings schema={schema.flows} config={config.flows} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FallbackRoutes = ({ module, schema, config, ...settingProps }) => {
|
||||
const FallbackRoutes = ({
|
||||
module,
|
||||
schema,
|
||||
config,
|
||||
...settingProps
|
||||
}: SettingProps<any> & { module: string }) => {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig();
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
|
||||
134
app/src/ui/styles.css
Normal file
134
app/src/ui/styles.css
Normal file
@@ -0,0 +1,134 @@
|
||||
@import "./main.css";
|
||||
@import "./components/form/json-schema/styles.css";
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
@import "@mantine/core/styles.css";
|
||||
@import "@mantine/notifications/styles.css";
|
||||
|
||||
html.fixed,
|
||||
html.fixed body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
.bknd-admin {
|
||||
--color-primary: 9 9 11; /* zinc-950 */
|
||||
--color-background: 250 250 250; /* zinc-50 */
|
||||
--color-muted: 228 228 231; /* ? */
|
||||
--color-darkest: 0 0 0; /* black */
|
||||
--color-lightest: 255 255 255; /* white */
|
||||
|
||||
&.dark {
|
||||
--color-primary: 250 250 250; /* zinc-50 */
|
||||
--color-background: 30 31 34;
|
||||
--color-muted: 47 47 52;
|
||||
--color-darkest: 255 255 255; /* white */
|
||||
--color-lightest: 24 24 27; /* black */
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
--mantine-color-body: rgb(250 250 250);
|
||||
}
|
||||
@mixin dark {
|
||||
--mantine-color-body: rgb(9 9 11);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.app-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.app-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div:first-child {
|
||||
display: block !important;
|
||||
min-width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* hide calendar icon on inputs */
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* cm */
|
||||
.cm-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeInAnimation 200ms ease;
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[readonly]::placeholder,
|
||||
input[disabled]::placeholder {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.react-flow__pane,
|
||||
.react-flow__renderer,
|
||||
.react-flow__node,
|
||||
.react-flow__edge {
|
||||
cursor: inherit !important;
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
.react-flow .react-flow__edge path,
|
||||
.react-flow__connectionline path {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mantine-TextInput-wrapper input {
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
background: transparent;
|
||||
}
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.flex-animate {
|
||||
transition: flex-grow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.flex-initial {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.flex-open {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
Reference in New Issue
Block a user