mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 21:06:04 +00:00
adding context menu to entities list
This commit is contained in:
@@ -64,7 +64,12 @@ export function useBkndData() {
|
|||||||
};
|
};
|
||||||
const $data = {
|
const $data = {
|
||||||
entity: (name: string) => entities[name],
|
entity: (name: string) => entities[name],
|
||||||
modals
|
modals,
|
||||||
|
system: (name: string) => ({
|
||||||
|
any: entities[name]?.type === "system",
|
||||||
|
users: name === config.auth.entity_name,
|
||||||
|
media: name === config.media.entity_name
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useClickOutside } from "@mantine/hooks";
|
import { useClickOutside } from "@mantine/hooks";
|
||||||
import { Fragment, type ReactElement, cloneElement, useState } from "react";
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
Fragment,
|
||||||
|
type ReactElement,
|
||||||
|
cloneElement,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useEvent } from "../../hooks/use-event";
|
import { useEvent } from "../../hooks/use-event";
|
||||||
|
|
||||||
@@ -14,26 +20,33 @@ export type DropdownItem =
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DropdownClickableChild = ReactElement<{ onClick: () => void }>;
|
||||||
export type DropdownProps = {
|
export type DropdownProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
openEvent?: "onClick" | "onContextMenu";
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
|
title?: string | ReactElement;
|
||||||
|
dropdownWrapperProps?: Omit<ComponentPropsWithoutRef<"div">, "style">;
|
||||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||||
hideOnEmpty?: boolean;
|
hideOnEmpty?: boolean;
|
||||||
items: (DropdownItem | undefined | boolean)[];
|
items: (DropdownItem | undefined | boolean)[];
|
||||||
itemsClassName?: string;
|
itemsClassName?: string;
|
||||||
children: ReactElement<{ onClick: () => void }>;
|
children: DropdownClickableChild;
|
||||||
onClickItem?: (item: DropdownItem) => void;
|
onClickItem?: (item: DropdownItem) => void;
|
||||||
renderItem?: (
|
renderItem?: (
|
||||||
item: DropdownItem,
|
item: DropdownItem,
|
||||||
props: { key: number; onClick: () => void }
|
props: { key: number; onClick: () => void }
|
||||||
) => ReactElement<{ onClick: () => void }>;
|
) => DropdownClickableChild;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Dropdown({
|
export function Dropdown({
|
||||||
children,
|
children,
|
||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
|
openEvent = "onClick",
|
||||||
position = "bottom-start",
|
position = "bottom-start",
|
||||||
|
dropdownWrapperProps,
|
||||||
items,
|
items,
|
||||||
|
title,
|
||||||
hideOnEmpty = true,
|
hideOnEmpty = true,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
renderItem,
|
renderItem,
|
||||||
@@ -48,6 +61,11 @@ export function Dropdown({
|
|||||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openEventHandler = useEvent((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
|
||||||
const offset = 4;
|
const offset = 4;
|
||||||
const dropdownStyle = {
|
const dropdownStyle = {
|
||||||
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
||||||
@@ -94,13 +112,26 @@ export function Dropdown({
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
<div
|
||||||
{cloneElement(children as any, { onClick: toggle })}
|
role="dropdown"
|
||||||
|
className={twMerge("relative flex", className)}
|
||||||
|
ref={clickoutsideRef}
|
||||||
|
onContextMenu={openEvent === "onContextMenu" ? openEventHandler : undefined}
|
||||||
|
>
|
||||||
|
{cloneElement(
|
||||||
|
children as any,
|
||||||
|
openEvent === "onClick" ? { onClick: openEventHandler } : {}
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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}
|
style={dropdownStyle}
|
||||||
>
|
>
|
||||||
|
{title && <div className="text-sm font-bold px-3 mb-1 mt-1 opacity-50">{title}</div>}
|
||||||
{menuItems.map((item, i) =>
|
{menuItems.map((item, i) =>
|
||||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const SidebarLink = <E extends React.ElementType = "a">({
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-row px-4 py-2.5 items-center gap-2",
|
"flex flex-row px-4 py-2.5 items-center gap-2",
|
||||||
!disabled &&
|
!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",
|
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PrimaryFieldType } from "core";
|
import type { PrimaryFieldType } from "core";
|
||||||
import { encodeSearch } from "core/utils";
|
import { encodeSearch } from "core/utils";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation, useRouter } from "wouter";
|
||||||
import { useBknd } from "../client/BkndProvider";
|
import { useBknd } from "../client/BkndProvider";
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
@@ -55,6 +55,7 @@ export function withAbsolute(url: string) {
|
|||||||
|
|
||||||
export function useNavigate() {
|
export function useNavigate() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
|
const router = useRouter();
|
||||||
const { app } = useBknd();
|
const { app } = useBknd();
|
||||||
const basepath = app.getAdminConfig().basepath;
|
const basepath = app.getAdminConfig().basepath;
|
||||||
return [
|
return [
|
||||||
@@ -69,6 +70,7 @@ export function useNavigate() {
|
|||||||
transition?: boolean;
|
transition?: boolean;
|
||||||
}
|
}
|
||||||
| { reload: true }
|
| { reload: true }
|
||||||
|
| { target: string }
|
||||||
) => {
|
) => {
|
||||||
const wrap = (fn: () => void) => {
|
const wrap = (fn: () => void) => {
|
||||||
fn();
|
fn();
|
||||||
@@ -81,9 +83,15 @@ export function useNavigate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
wrap(() => {
|
wrap(() => {
|
||||||
if (options && "reload" in options) {
|
if (options) {
|
||||||
window.location.href = url;
|
if ("reload" in options) {
|
||||||
return;
|
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;
|
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TbToggleLeft
|
TbToggleLeft
|
||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
|
|
||||||
type TFieldSpec = {
|
export type TFieldSpec = {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||||
import { IconDatabase } from "@tabler/icons-react";
|
import {
|
||||||
|
IconAlignJustified,
|
||||||
|
IconCirclesRelation,
|
||||||
|
IconDatabase,
|
||||||
|
IconExternalLink,
|
||||||
|
IconPhoto,
|
||||||
|
IconPlus,
|
||||||
|
IconSettings
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import type { Entity, TEntityType } from "data";
|
import type { Entity, TEntityType } from "data";
|
||||||
import { TbDatabasePlus } from "react-icons/tb";
|
import { TbDatabasePlus } from "react-icons/tb";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
import { Empty } from "ui/components/display/Empty";
|
||||||
|
import { Dropdown, type DropdownClickableChild } from "ui/components/overlay/Dropdown";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
@@ -125,15 +134,92 @@ const EntityLinkList = ({
|
|||||||
? routes.data.entity.list(entity.name)
|
? routes.data.entity.list(entity.name)
|
||||||
: routes.data.schema.entity(entity.name);
|
: routes.data.schema.entity(entity.name);
|
||||||
return (
|
return (
|
||||||
<AppShell.SidebarLink key={entity.name} as={Link} href={href}>
|
<EntityContextMenu key={entity.name} entity={entity}>
|
||||||
{entity.label}
|
<AppShell.SidebarLink as={Link} href={href}>
|
||||||
</AppShell.SidebarLink>
|
{entity.label}
|
||||||
|
</AppShell.SidebarLink>
|
||||||
|
</EntityContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</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 new 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: "Advanced settings",
|
||||||
|
onClick: () =>
|
||||||
|
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||||
|
absolute: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
openEvent="onContextMenu"
|
||||||
|
position="bottom-start"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function DataEmpty() {
|
export function DataEmpty() {
|
||||||
useBrowserTitle(["Data"]);
|
useBrowserTitle(["Data"]);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isDebug } from "core";
|
|||||||
import type { Entity } from "data";
|
import type { Entity } from "data";
|
||||||
import { cloneDeep } from "lodash-es";
|
import { cloneDeep } from "lodash-es";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { TbCirclesRelation, TbDots, TbPhoto, TbPlus } from "react-icons/tb";
|
import { TbCirclesRelation, TbDots, TbPhoto, TbPlus, TbSitemap } from "react-icons/tb";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
@@ -16,9 +16,11 @@ import { Empty } from "ui/components/display/Empty";
|
|||||||
import { Message } from "ui/components/display/Message";
|
import { Message } from "ui/components/display/Message";
|
||||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||||
|
import { Link } from "ui/components/wouter/Link";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||||
import { routes, useNavigate } from "ui/lib/routes";
|
import { routes, useNavigate } from "ui/lib/routes";
|
||||||
|
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||||
import { extractSchema } from "../settings/utils/schema";
|
import { extractSchema } from "../settings/utils/schema";
|
||||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||||
|
|
||||||
@@ -87,10 +89,15 @@ export function DataSchemaEntity({ params }) {
|
|||||||
}
|
}
|
||||||
className="pl-3"
|
className="pl-3"
|
||||||
>
|
>
|
||||||
<Breadcrumbs2
|
<div className="flex flex-row gap-4">
|
||||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
<Breadcrumbs2
|
||||||
backTo="/"
|
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||||
/>
|
backTo="/"
|
||||||
|
/>
|
||||||
|
<Link to="/" className="invisible md:visible">
|
||||||
|
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<div className="flex flex-col h-full" key={entity.name}>
|
<div className="flex flex-col h-full" key={entity.name}>
|
||||||
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
|
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
|
||||||
@@ -142,7 +149,7 @@ const Fields = ({
|
|||||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [updates, setUpdates] = useState(0);
|
const [updates, setUpdates] = useState(0);
|
||||||
const { actions } = useBkndData();
|
const { actions, $data } = useBkndData();
|
||||||
const [res, setRes] = useState<any>();
|
const [res, setRes] = useState<any>();
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
@@ -175,7 +182,30 @@ const Fields = ({
|
|||||||
{submitting && (
|
{submitting && (
|
||||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
<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() && (
|
{isDebug() && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitc
|
|||||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||||
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
|
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
|
||||||
import { Popover } from "ui/components/overlay/Popover";
|
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";
|
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||||
|
|
||||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||||
@@ -45,7 +45,6 @@ type TFieldsFormSchema = Static<typeof schema>;
|
|||||||
|
|
||||||
const fieldTypes = Object.keys(fieldsSchemaObject);
|
const fieldTypes = Object.keys(fieldsSchemaObject);
|
||||||
const defaultType = fieldTypes[0];
|
const defaultType = fieldTypes[0];
|
||||||
const blank_field = { name: "", field: { type: defaultType, config: {} } } as TFieldSchema;
|
|
||||||
const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"];
|
const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"];
|
||||||
|
|
||||||
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
|
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
|
||||||
@@ -53,6 +52,13 @@ function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
|
|||||||
return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps);
|
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 = {
|
export type EntityFieldsFormRef = {
|
||||||
getValues: () => TFieldsFormSchema;
|
getValues: () => TFieldsFormSchema;
|
||||||
getData: () => TAppDataEntityFields;
|
getData: () => TAppDataEntityFields;
|
||||||
@@ -60,146 +66,156 @@ export type EntityFieldsFormRef = {
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EntityFieldsForm = forwardRef<
|
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||||
EntityFieldsFormRef,
|
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
|
||||||
{
|
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||||
fields: TAppDataEntityFields;
|
name,
|
||||||
onChange?: (formData: TAppDataEntityFields) => void;
|
field
|
||||||
sortable?: boolean;
|
}));
|
||||||
}
|
|
||||||
>(function EntityFieldsForm({ fields: _fields, sortable, ...props }, ref) {
|
|
||||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
|
||||||
name,
|
|
||||||
field
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { isValid, errors },
|
formState: { isValid, errors },
|
||||||
getValues,
|
getValues,
|
||||||
watch,
|
watch,
|
||||||
register,
|
register,
|
||||||
setValue,
|
setValue,
|
||||||
setError,
|
setError,
|
||||||
reset
|
reset
|
||||||
} = useForm({
|
} = useForm({
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: typeboxResolver(schema),
|
resolver: typeboxResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fields: entityFields
|
fields: entityFields
|
||||||
} as TFieldsFormSchema
|
} as TFieldsFormSchema
|
||||||
});
|
});
|
||||||
const { fields, append, remove, move } = useFieldArray({
|
const { fields, append, remove, move } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: "fields"
|
name: "fields"
|
||||||
});
|
});
|
||||||
|
|
||||||
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
|
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
|
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
reset,
|
if (props?.onChange) {
|
||||||
getValues: () => getValues(),
|
console.log("----set");
|
||||||
getData: () => {
|
watch((data: any) => {
|
||||||
return toCleanValues(getValues());
|
console.log("---calling");
|
||||||
},
|
props?.onChange?.(toCleanValues(data));
|
||||||
isValid: () => isValid
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
|
|
||||||
const newField = {
|
|
||||||
name: "",
|
|
||||||
new: true,
|
|
||||||
field: {
|
|
||||||
type: _type,
|
|
||||||
config: {}
|
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
append(newField);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formProps = {
|
useImperativeHandle(ref, () => ({
|
||||||
watch,
|
reset,
|
||||||
register,
|
getValues: () => getValues(),
|
||||||
setValue,
|
getData: () => {
|
||||||
getValues,
|
return toCleanValues(getValues());
|
||||||
control,
|
},
|
||||||
setError
|
isValid: () => isValid
|
||||||
};
|
}));
|
||||||
return (
|
|
||||||
<>
|
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
|
||||||
<div className="flex flex-col gap-6">
|
const newField = {
|
||||||
<div className="flex flex-col gap-3">
|
name: "",
|
||||||
<div className="flex flex-col gap-4">
|
new: true,
|
||||||
{sortable ? (
|
field: {
|
||||||
<SortableList
|
type: _type,
|
||||||
data={fields}
|
config: {}
|
||||||
key={fields.length}
|
}
|
||||||
onReordered={move}
|
};
|
||||||
extractId={(item) => item.id}
|
append(newField);
|
||||||
disableIndices={[0]}
|
}
|
||||||
renderItem={({ dnd, ...props }, index) => (
|
|
||||||
<EntityFieldMemo
|
const formProps = {
|
||||||
key={props.id}
|
watch,
|
||||||
field={props as any}
|
register,
|
||||||
index={index}
|
setValue,
|
||||||
form={formProps}
|
getValues,
|
||||||
errors={errors}
|
control,
|
||||||
remove={remove}
|
setError
|
||||||
dnd={dnd}
|
};
|
||||||
|
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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
) : (
|
<Button className="justify-center">Add Field</Button>
|
||||||
<div>
|
</Popover>
|
||||||
{fields.map((field, index) => (
|
</div>
|
||||||
<EntityField
|
|
||||||
key={field.id}
|
|
||||||
field={field as any}
|
|
||||||
index={index}
|
|
||||||
form={formProps}
|
|
||||||
errors={errors}
|
|
||||||
remove={remove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
className="flex flex-col w-full"
|
|
||||||
target={({ toggle }) => (
|
|
||||||
<SelectType
|
|
||||||
onSelect={(type) => {
|
|
||||||
handleAppend(type as any);
|
|
||||||
toggle();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button className="justify-center">Add Field</Button>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
|
const SelectType = ({
|
||||||
const types = fieldSpecs.filter((s) => s.addable !== false);
|
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 (
|
return (
|
||||||
<div className="flex flex-row gap-2 justify-center flex-wrap">
|
<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}
|
key={type.type}
|
||||||
IconLeft={type.icon}
|
IconLeft={type.icon}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onSelect(type.type)}
|
onClick={() => {
|
||||||
|
if (type.addable) {
|
||||||
|
onSelect(type.type);
|
||||||
|
} else {
|
||||||
|
type.onClick?.();
|
||||||
|
}
|
||||||
|
onSelected?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{type.label}
|
{type.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user