mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
adding context menu to entities list
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
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 { TbDatabasePlus } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
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";
|
||||
@@ -125,15 +134,92 @@ 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 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() {
|
||||
useBrowserTitle(["Data"]);
|
||||
const [navigate] = useNavigate();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isDebug } from "core";
|
||||
import type { Entity } from "data";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
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 { Button } from "ui/components/buttons/Button";
|
||||
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 { 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";
|
||||
|
||||
@@ -87,10 +89,15 @@ export function DataSchemaEntity({ params }) {
|
||||
}
|
||||
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")} />
|
||||
@@ -142,7 +149,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() {
|
||||
@@ -175,7 +182,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>
|
||||
|
||||
@@ -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) {
|
||||
onSelect(type.type);
|
||||
} else {
|
||||
type.onClick?.();
|
||||
}
|
||||
onSelected?.();
|
||||
}}
|
||||
>
|
||||
{type.label}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user