mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
introduced auth strategy actions to allow user creation in UI
This commit is contained in:
@@ -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));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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} />;
|
||||
|
||||
export const Message = {
|
||||
NotFound
|
||||
NotFound,
|
||||
NotAllowed
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
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 { modals as $modals, ModalsProvider, closeModal, openContextModal } from "@mantine/modals";
|
||||
import { transformObject } from "core/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { OverlayModal } from "ui/modals/debug/OverlayModal";
|
||||
import { DebugModal } from "./debug/DebugModal";
|
||||
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
||||
import { TestModal } from "./debug/TestModal";
|
||||
@@ -9,7 +10,8 @@ import { TestModal } from "./debug/TestModal";
|
||||
const modals = {
|
||||
test: TestModal,
|
||||
debug: DebugModal,
|
||||
form: SchemaFormModal
|
||||
form: SchemaFormModal,
|
||||
overlay: OverlayModal
|
||||
};
|
||||
|
||||
declare module "@mantine/modals" {
|
||||
@@ -33,17 +35,22 @@ 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 = {
|
||||
@@ -53,5 +60,5 @@ export const bkndModals = {
|
||||
>,
|
||||
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 };
|
||||
}
|
||||
@@ -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} />;
|
||||
|
||||
@@ -28,14 +28,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"
|
||||
|
||||
@@ -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,8 @@ export function DataEntityUpdate({ params }) {
|
||||
with: local_relation_refs
|
||||
},
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false
|
||||
}
|
||||
);
|
||||
|
||||
@@ -81,6 +87,14 @@ export function DataEntityUpdate({ params }) {
|
||||
onSubmitted
|
||||
});
|
||||
|
||||
if (!data && !$q.isLoading) {
|
||||
return (
|
||||
<Message.NotFound
|
||||
description={`Entity "${params.entity}" with ID "${entityId}" doesn't exist.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const makeKey = (key: string | number = "") =>
|
||||
`${params.entity.name}_${entityId}_${String(key)}`;
|
||||
|
||||
|
||||
@@ -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,7 +32,11 @@ 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, {
|
||||
@@ -39,13 +46,14 @@ export function DataEntityList({ params }) {
|
||||
|
||||
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
|
||||
}),
|
||||
{
|
||||
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={
|
||||
<>
|
||||
@@ -100,14 +104,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} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -140,6 +137,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user