diff --git a/app/src/ui/client/schema/data/use-bknd-data.ts b/app/src/ui/client/schema/data/use-bknd-data.ts index e5342c6..0886fed 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -64,7 +64,12 @@ export function useBkndData() { }; const $data = { 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 { diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index f6616e9..8081e1c 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -1,5 +1,11 @@ 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 { useEvent } from "../../hooks/use-event"; @@ -14,26 +20,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, "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, + openEvent = "onClick", position = "bottom-start", + dropdownWrapperProps, items, + title, hideOnEmpty = true, onClickItem, renderItem, @@ -48,6 +61,11 @@ export function Dropdown({ setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0) ); + const openEventHandler = useEvent((e) => { + e.preventDefault(); + toggle(); + }); + const offset = 4; const dropdownStyle = { "bottom-start": { top: "100%", left: 0, marginTop: offset }, @@ -94,13 +112,26 @@ export function Dropdown({ )); return ( -
- {cloneElement(children as any, { onClick: toggle })} +
+ {cloneElement( + children as any, + openEvent === "onClick" ? { onClick: openEventHandler } : {} + )} {open && (
+ {title &&
{title}
} {menuItems.map((item, i) => itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }) )} diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index e61e1d5..e10839a 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -191,7 +191,7 @@ export const SidebarLink = ({ 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 )} diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 44818fe..37f404a 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -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; diff --git a/app/src/ui/modules/data/components/fields-specs.ts b/app/src/ui/modules/data/components/fields-specs.ts index e182113..79517fe 100644 --- a/app/src/ui/modules/data/components/fields-specs.ts +++ b/app/src/ui/modules/data/components/fields-specs.ts @@ -10,7 +10,7 @@ import { TbToggleLeft } from "react-icons/tb"; -type TFieldSpec = { +export type TFieldSpec = { type: string; label: string; icon: any; diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index 1ad24fa..a04c100 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -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 ( - - {entity.label} - + + + {entity.label} + + ); })} ); }; +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 = () =>
; + + return ( + 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} + + ); +}; + export function DataEmpty() { useBrowserTitle(["Data"]); const [navigate] = useNavigate(); diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index b477b15..de9b2f9 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -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" > - +
+ + + + +
@@ -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(); const ref = useRef(null); async function handleUpdate() { @@ -175,7 +182,30 @@ const Fields = ({ {submitting && (
)} - + ["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() && (
diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index bcc315d..55767cc 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -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; 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( + 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 ( - <> -
-
-
- {sortable ? ( - item.id} - disableIndices={[0]} - renderItem={({ dnd, ...props }, index) => ( - ({ + 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 ( + <> +
+
+
+ {sortable ? ( + item.id} + disableIndices={[0]} + renderItem={({ dnd, ...props }, index) => ( + + )} + /> + ) : ( +
+ {fields.map((field, index) => ( + + ))} +
+ )} + + ( + { + handleAppend(type as any); + }} /> )} - /> - ) : ( -
- {fields.map((field, index) => ( - - ))} -
- )} - - ( - { - handleAppend(type as any); - toggle(); - }} - /> - )} - > - - + > + +
+
-
- - ); -}); + + ); + } +); -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 (
@@ -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}