Skip to content

Commit

Permalink
feat: add tooltip, helper text and notice (#192)
Browse files Browse the repository at this point in the history
  • Loading branch information
foyarash authored Mar 21, 2024
1 parent 03bdc6d commit 3fa5cd7
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 47 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-fans-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@premieroctet/next-admin": minor
---

feat: add tooltip, helper text and notice
24 changes: 18 additions & 6 deletions apps/docs/pages/docs/api-docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,12 @@ This property determines how your data is displayed in the [List View](/docs/glo

This property determines how your data is displayed in the [edit view](/docs/glossary#edit-view)

| Name | Description | Default value |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `display` | an array of fields that are displayed in the form | all scalar fields are displayed |
| `styles` | an object containing the styles of the form | undefined |
| `fields` | an object containing the model fields as keys, and customization values | undefined |
| `submissionErrorMessage` | a message displayed if an error occurs during the form submission, after the form validation and before any call to prisma | Submission error |
| Name | Description | Default value |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `display` | an array of fields that are displayed in the form. It can also be an object that will be displayed in the form of a notice. See [notice](#notice) | all scalar fields are displayed |
| `styles` | an object containing the styles of the form | undefined |
| `fields` | an object containing the model fields as keys, and customization values | undefined |
| `submissionErrorMessage` | a message displayed if an error occurs during the form submission, after the form validation and before any call to prisma | Submission error |

##### `styles` property

Expand Down Expand Up @@ -283,6 +283,8 @@ For the `edit` property, it can take the following:
| `handler.upload` | an async function that is used only for formats `file` and `data-url`. It takes a buffer as parameter and must return a string. Useful to upload a file to a remote provider |
| `handler.uploadErrorMessage` | an optional string displayed in the input field as an error message in case of a failure during the upload handler |
| `optionFormatter` | only for realtion fields, a function that takes the field values as a parameter and returns a string. Useful to display your record in related list |
| `tooltip` | a tooltip content to show for the field |
| `helperText` | a helper text that is displayed underneath the input |

### `pages`

Expand Down Expand Up @@ -405,3 +407,13 @@ This is the type of the props that are passed to the custom input component:
The `NextAdmin` context is an object containing the following properties:

- `locale`: the locale used by the calling page. (refers to the `accept-language` header).

## Notice

The edit page's form can display notice alerts. To do so, you can pass objects in the `display` array of the `edit` property of a model. This object takes the following properties :

| Name | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------- |
| title | The title of the notice. This is mandatory |
| id | A unique identifier for the notice that can be used to style it with the `styles` property. This is mandatory |
| description | The description of the notice. This is optional |
22 changes: 15 additions & 7 deletions apps/example/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const options: NextAdminOptions = {
display: [
"id",
"name",
{
title: "Email is mandatory",
id: "email-notice",
description: "You must add an email from now on",
} as const,
"email",
"posts",
"role",
Expand All @@ -43,18 +48,21 @@ export const options: NextAdminOptions = {
],
styles: {
_form: "grid-cols-3 gap-2 md:grid-cols-4",
id: "col-span-2",
id: "col-span-2 row-start-1",
name: "col-span-2 row-start-2",
email: "col-span-2 row-start-3",
posts: "col-span-2 row-start-4",
role: "col-span-2 row-start-4",
birthDate: "col-span-3 row-start-5",
avatar: "col-span-4 row-start-6",
metadata: "col-span-4 row-start-7",
"email-notice": "col-span-4 row-start-3",
email: "col-span-2 row-start-4",
posts: "col-span-2 row-start-5",
role: "col-span-2 row-start-6",
birthDate: "col-span-3 row-start-7",
avatar: "col-span-4 row-start-8",
metadata: "col-span-4 row-start-9",
},
fields: {
email: {
validate: (email) => email.includes("@") || "form.user.email.error",
helperText: "Must be a valid email address",
tooltip: "Make sure to include the @",
},
birthDate: {
input: <DatePicker />,
Expand Down
1 change: 1 addition & 0 deletions packages/next-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@picocss/pico": "^1.5.7",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@rjsf/core": "^5.3.0",
"@rjsf/utils": "^5.3.0",
"@rjsf/validator-ajv8": "^5.3.0",
Expand Down
59 changes: 49 additions & 10 deletions packages/next-admin/src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@rjsf/utils";
import validator from "@rjsf/validator-ajv8";
import clsx from "clsx";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
import React, { ChangeEvent, cloneElement, useMemo, useState } from "react";
import { useConfig } from "../context/ConfigContext";
import { FormContext, FormProvider } from "../context/FormContext";
Expand All @@ -28,6 +29,7 @@ import {
import { getSchemas } from "../utils/jsonSchema";
import ActionsDropdown from "./ActionsDropdown";
import ArrayField from "./inputs/ArrayField";
import NullField from "./inputs/NullField";
import CheckboxWidget from "./inputs/CheckboxWidget";
import DateTimeWidget from "./inputs/DateTimeWidget";
import DateWidget from "./inputs/DateWidget";
Expand All @@ -38,6 +40,13 @@ import SelectWidget from "./inputs/SelectWidget";
import TextareaWidget from "./inputs/TextareaWidget";
import Button from "./radix/Button";
import ResourceIcon from "./common/ResourceIcon";
import {
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from "./radix/Tooltip";

// Override Form functions to not prevent the submit
class CustomForm extends RjsfForm {
Expand Down Expand Up @@ -68,6 +77,7 @@ export type FormProps = {

const fields: CustomForm["props"]["fields"] = {
ArrayField,
NullField,
};

const widgets: CustomForm["props"]["widgets"] = {
Expand Down Expand Up @@ -215,27 +225,51 @@ const Form = ({
description,
errors,
children,
schema,
} = props;
const labelAlias =
options?.aliases?.[id as Field<typeof resource>] || label;
let styleField = options?.edit?.styles?.[id as Field<typeof resource>];

const tooltip =
options?.edit?.fields?.[id as Field<typeof resource>]?.tooltip;
const sanitizedClassNames = classNames
?.split(",")
.filter((className) => className !== "null")
.join(" ");
return (
<div style={style} className={clsx(sanitizedClassNames, styleField)}>
<label
className={clsx(
"block text-sm font-medium leading-6 text-gray-900 capitalize"
)}
htmlFor={id}
>
{labelAlias}
{required ? "*" : null}
</label>
{description}
{schema.type !== "null" && (
<label
className={clsx(
"flex items-center text-sm font-medium leading-6 text-gray-900 capitalize gap-2"
)}
htmlFor={id}
>
{labelAlias}
{required ? "*" : null}
{!!tooltip && (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<InformationCircleIcon className="w-4 h-4 text-gray-500" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="right"
className="px-2 py-1"
sideOffset={4}
>
{tooltip}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
)}
</label>
)}
{children}
{description}
{errors}
{help}
</div>
Expand Down Expand Up @@ -331,6 +365,11 @@ const Form = ({
</div>
) : null;
},
DescriptionFieldTemplate: ({ description, schema }) => {
return description && schema.type !== "null" ? (
<span className="text-sm text-gray-500">{description}</span>
) : null;
},
}),
[customInputs]
);
Expand Down
30 changes: 30 additions & 0 deletions packages/next-admin/src/components/inputs/NullField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { InformationCircleIcon } from "@heroicons/react/24/solid";
import { FieldProps } from "@rjsf/utils";
import { useI18n } from "../../context/I18nContext";

const NullField = ({ schema }: FieldProps) => {
const { t } = useI18n();

return (
<div className="rounded-md bg-blue-50 p-4 w-full">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon
className="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
{t(schema.title!)}
</h3>
{!!schema.description && (
<p className="text-sm text-blue-700">{t(schema.description)}</p>
)}
</div>
</div>
</div>
);
};

export default NullField;
25 changes: 25 additions & 0 deletions packages/next-admin/src/components/radix/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as Tooltip from "@radix-ui/react-tooltip";
import { forwardRef } from "react";
import clsx from "clsx";

export const TooltipProvider = Tooltip.Provider;
export const TooltipRoot = Tooltip.Root;
export const TooltipTrigger = Tooltip.Trigger;
export const TooltipArrow = Tooltip.Arrow;
export const TooltipPortal = Tooltip.Portal;

export const TooltipContent = forwardRef<
React.ElementRef<typeof Tooltip.Content>,
Tooltip.TooltipContentProps
>(({ className, ...props }, ref) => {
return (
<Tooltip.Content
{...props}
className={clsx(
"text-sm text-gray-500 border border-slate-100 bg-slate-50 shadow-xl rounded",
className
)}
ref={ref}
/>
);
});
58 changes: 35 additions & 23 deletions packages/next-admin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type Payload = Prisma.TypeMap["model"][ModelName]["payload"];

export type ModelFromPayload<
P extends Payload,
T extends object | number = object,
T extends object | number = object
> = {
[Property in keyof P["scalars"]]: P["scalars"][Property];
} & {
Expand All @@ -32,41 +32,47 @@ export type ModelFromPayload<
? S
: T
: never | P["objects"][Property] extends { scalars: infer S }[]
? T extends object
? S[]
: T[]
: never | P["objects"][Property] extends { scalars: infer S } | null
? T extends object
? S | null
: T | null
: never;
? T extends object
? S[]
: T[]
: never | P["objects"][Property] extends { scalars: infer S } | null
? T extends object
? S | null
: T | null
: never;
};

export type Model<
M extends ModelName,
T extends object | number = object,
T extends object | number = object
> = ModelFromPayload<Prisma.TypeMap["model"][M]["payload"], T>;

export type PropertyPayload<
M extends ModelName,
P extends keyof ObjectField<M>,
P extends keyof ObjectField<M>
> = Prisma.TypeMap["model"][M]["payload"]["objects"][P] extends Array<infer T>
? T
: never | Prisma.TypeMap["model"][M]["payload"]["objects"][P] extends
| infer T
| null
? T
: never | Prisma.TypeMap["model"][M]["payload"]["objects"][P];
| infer T
| null
? T
: never | Prisma.TypeMap["model"][M]["payload"]["objects"][P];

export type ModelFromProperty<
M extends ModelName,
P extends keyof ObjectField<M>,
P extends keyof ObjectField<M>
> = PropertyPayload<M, P> extends Payload
? ModelFromPayload<PropertyPayload<M, P>>
: never;

export type ModelWithoutRelationships<M extends ModelName> = Model<M, number>;

export type NoticeField = {
readonly id: string;
title: string;
description?: string;
};

export type Field<P extends ModelName> = keyof Model<P>;

/** Type for Form */
Expand All @@ -90,6 +96,8 @@ export type EditFieldsOptions<T extends ModelName> = {
format?: FormatOptions<ModelWithoutRelationships<T>[P]>;
handler?: Handler<T, P, Model<T>[P]>;
input?: React.ReactElement;
helperText?: string;
tooltip?: string;
} & (P extends keyof ObjectField<T>
? {
optionFormatter?: (item: ModelFromProperty<T, P>) => string;
Expand All @@ -100,7 +108,7 @@ export type EditFieldsOptions<T extends ModelName> = {
export type Handler<
M extends ModelName,
P extends Field<M>,
T extends Model<M>[P],
T extends Model<M>[P]
> = {
get?: (input: T) => any;
upload?: (file: Buffer) => Promise<string>;
Expand All @@ -126,10 +134,10 @@ export type FormatOptions<T> = T extends string
| `richtext-${RichTextFormat}`
| "json"
: never | T extends Date
? "date" | "date-time" | "time"
: never | T extends number
? "updown" | "range"
: never;
? "date" | "date-time" | "time"
: never | T extends number
? "updown" | "range"
: never;

export type ListOptions<T extends ModelName> = {
display?: Field<T>[];
Expand All @@ -138,10 +146,14 @@ export type ListOptions<T extends ModelName> = {
};

export type EditOptions<T extends ModelName> = {
display?: Field<T>[];
display?: Array<Field<T> | NoticeField>;
styles?: {
_form?: string;
} & Partial<Record<Field<T>, string>>;
} & Partial<
{
[Key in Field<T>]: string;
} & Record<string, string>
>;
fields?: EditFieldsOptions<T>;
submissionErrorMessage?: string;
};
Expand Down
Loading

0 comments on commit 3fa5cd7

Please sign in to comment.