page-layout.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
  2. import { PermissionGuard } from '@/components/shared/permission-guard.js';
  3. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
  4. import { Form } from '@/components/ui/form.js';
  5. import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
  6. import { usePage } from '@/hooks/use-page.js';
  7. import { cn } from '@/lib/utils.js';
  8. import { NavigationConfirmation } from '@/components/shared/navigation-confirmation.js';
  9. import { useMediaQuery } from '@uidotdev/usehooks';
  10. import React, { ComponentProps, createContext } from 'react';
  11. import { Control, UseFormReturn } from 'react-hook-form';
  12. import { DashboardActionBarItem } from '../extension-api/extension-api-types.js';
  13. import { getDashboardActionBarItems, getDashboardPageBlocks } from './layout-extensions.js';
  14. import { LocationWrapper } from './location-wrapper.js';
  15. export interface PageProps extends ComponentProps<'div'> {
  16. pageId?: string;
  17. entity?: any;
  18. form?: UseFormReturn<any>;
  19. submitHandler?: any;
  20. }
  21. export const PageContext = createContext<PageContextValue | undefined>(undefined);
  22. export function Page({ children, pageId, entity, form, submitHandler, ...props }: PageProps) {
  23. const childArray = React.Children.toArray(children);
  24. const pageTitle = childArray.find(child => React.isValidElement(child) && child.type === PageTitle);
  25. const pageActionBar = childArray.find(
  26. child => isOfType(child, PageActionBar),
  27. );
  28. const pageContent = childArray.filter(
  29. child => !isOfType(child, PageTitle) && !isOfType(child, PageActionBar),
  30. );
  31. const pageHeader = (
  32. <div className="flex items-center justify-between">
  33. {pageTitle}
  34. {pageActionBar}
  35. </div>
  36. );
  37. return (
  38. <PageContext.Provider value={{ pageId, form, entity }}>
  39. <PageContent
  40. pageHeader={pageHeader}
  41. pageContent={pageContent}
  42. form={form}
  43. submitHandler={submitHandler}
  44. className={props.className}
  45. {...props}
  46. />
  47. </PageContext.Provider>
  48. );
  49. }
  50. function PageContent({ pageHeader, pageContent, form, submitHandler, ...props }: {
  51. pageHeader: React.ReactElement;
  52. pageContent: React.ReactElement;
  53. form?: UseFormReturn<any>;
  54. submitHandler?: any;
  55. className?: string;
  56. }) {
  57. return (
  58. <div className={cn('m-4', props.className)} {...props}>
  59. <LocationWrapper>
  60. <PageContentWithOptionalForm
  61. pageHeader={pageHeader}
  62. pageContent={pageContent}
  63. form={form}
  64. submitHandler={submitHandler}
  65. />
  66. </LocationWrapper>
  67. </div>
  68. );
  69. }
  70. export function PageContentWithOptionalForm({ form, pageHeader, pageContent, submitHandler }: {
  71. form?: UseFormReturn<any>;
  72. pageHeader: React.ReactElement
  73. pageContent: React.ReactElement;
  74. submitHandler?: any;
  75. }) {
  76. return form ? (
  77. <Form {...form}>
  78. <NavigationConfirmation form={form} />
  79. <form onSubmit={submitHandler} className="space-y-4">
  80. {pageHeader}
  81. {pageContent}
  82. </form>
  83. </Form>
  84. ) : (
  85. <div className="space-y-4">
  86. {pageHeader}
  87. {pageContent}
  88. </div>
  89. );
  90. }
  91. export type PageLayoutProps = {
  92. children: React.ReactNode;
  93. className?: string;
  94. };
  95. function isPageBlock(child: unknown): child is React.ReactElement<PageBlockProps> {
  96. if (!child) {
  97. return false;
  98. }
  99. if (!React.isValidElement(child)) {
  100. return false;
  101. }
  102. const props = (child as React.ReactElement<PageBlockProps>).props;
  103. const hasColumn = 'column' in props;
  104. const hasBlockId = 'blockId' in props;
  105. return hasColumn || hasBlockId;
  106. }
  107. export function PageLayout({ children, className }: PageLayoutProps) {
  108. const page = usePage();
  109. const isDesktop = useMediaQuery('only screen and (min-width : 769px)');
  110. // Separate blocks into categories
  111. const childArray: React.ReactElement<PageBlockProps>[] = [];
  112. const extensionBlocks = getDashboardPageBlocks(page.pageId ?? '');
  113. React.Children.forEach(children, child => {
  114. if (isPageBlock(child)) {
  115. childArray.push(child);
  116. }
  117. // check for a React Fragment
  118. if (React.isValidElement(child) && child.type === React.Fragment) {
  119. React.Children.forEach((child as React.ReactElement<PageBlockProps>).props.children, child => {
  120. if (isPageBlock(child)) {
  121. childArray.push(child);
  122. }
  123. });
  124. }
  125. });
  126. const finalChildArray: React.ReactElement<PageBlockProps>[] = [];
  127. for (const childBlock of childArray) {
  128. if (childBlock) {
  129. const blockId =
  130. childBlock.props.blockId ??
  131. (isOfType(childBlock, CustomFieldsPageBlock) ? 'custom-fields' : undefined);
  132. const extensionBlock = extensionBlocks.find(block => block.location.position.blockId === blockId);
  133. if (extensionBlock) {
  134. const ExtensionBlock = (
  135. <PageBlock
  136. column={extensionBlock.location.column}
  137. blockId={extensionBlock.id}
  138. title={extensionBlock.title}
  139. >
  140. {<extensionBlock.component context={page} />}
  141. </PageBlock>
  142. );
  143. if (extensionBlock.location.position.order === 'before') {
  144. finalChildArray.push(ExtensionBlock, childBlock);
  145. } else if (extensionBlock.location.position.order === 'after') {
  146. finalChildArray.push(childBlock, ExtensionBlock);
  147. } else if (extensionBlock.location.position.order === 'replace') {
  148. finalChildArray.push(ExtensionBlock);
  149. }
  150. } else {
  151. finalChildArray.push(childBlock);
  152. }
  153. }
  154. }
  155. const fullWidthBlocks = finalChildArray.filter(
  156. child => isPageBlock(child) && isOfType(child, FullWidthPageBlock),
  157. );
  158. const mainBlocks = finalChildArray.filter(child => isPageBlock(child) && child.props.column === 'main');
  159. const sideBlocks = finalChildArray.filter(child => isPageBlock(child) && child.props.column === 'side');
  160. return (
  161. <div className={cn('w-full space-y-4', className)}>
  162. {isDesktop ? (
  163. <div className="hidden md:grid md:grid-cols-5 lg:grid-cols-4 md:gap-4">
  164. {fullWidthBlocks.length > 0 && (
  165. <div className="md:col-span-5 space-y-4">{fullWidthBlocks}</div>
  166. )}
  167. <div className="md:col-span-3 space-y-4">{mainBlocks}</div>
  168. <div className="md:col-span-2 lg:col-span-1 space-y-4">{sideBlocks}</div>
  169. </div>
  170. ) : (
  171. <div className="md:hidden space-y-4">{children}</div>
  172. )}
  173. </div>
  174. );
  175. }
  176. export function DetailFormGrid({ children }: { children: React.ReactNode }) {
  177. return <div className="md:grid md:grid-cols-2 gap-4 items-start mb-4">{children}</div>;
  178. }
  179. export interface PageContextValue {
  180. pageId?: string;
  181. entity?: any;
  182. form?: UseFormReturn<any>;
  183. }
  184. export function PageTitle({ children }: { children: React.ReactNode }) {
  185. return <h1 className="text-2xl font-semibold">{children}</h1>;
  186. }
  187. export function PageActionBar({ children }: { children: React.ReactNode }) {
  188. let childArray = React.Children.toArray(children);
  189. const leftContent = childArray.filter(
  190. child => isOfType(child, PageActionBarLeft),
  191. );
  192. const rightContent = childArray.filter(
  193. child => isOfType(child, PageActionBarRight),
  194. );
  195. return (
  196. <div className={cn('flex gap-2', leftContent.length > 0 ? 'justify-between' : 'justify-end')}>
  197. {leftContent.length > 0 && <div className="flex justify-start gap-2">{leftContent}</div>}
  198. {rightContent.length > 0 && <div className="flex justify-end gap-2">{rightContent}</div>}
  199. </div>
  200. );
  201. }
  202. export function PageActionBarLeft({ children }: { children: React.ReactNode }) {
  203. return <div className="flex justify-start gap-2">{children}</div>;
  204. }
  205. export function PageActionBarRight({ children }: { children: React.ReactNode }) {
  206. const page = usePage();
  207. const actionBarItems = page.pageId ? getDashboardActionBarItems(page.pageId) : [];
  208. return (
  209. <div className="flex justify-end gap-2">
  210. {actionBarItems.map((item, index) => (
  211. <PageActionBarItem key={index} item={item} page={page} />
  212. ))}
  213. {children}
  214. </div>
  215. );
  216. }
  217. function PageActionBarItem({ item, page }: { item: DashboardActionBarItem; page: PageContextValue }) {
  218. return (
  219. <PermissionGuard requires={item.requiresPermission ?? []}>
  220. <item.component context={page} />
  221. </PermissionGuard>
  222. );
  223. }
  224. export type PageBlockProps = {
  225. children?: React.ReactNode;
  226. /** Which column this block should appear in */
  227. column: 'main' | 'side';
  228. blockId?: string;
  229. title?: React.ReactNode | string;
  230. description?: React.ReactNode | string;
  231. className?: string;
  232. };
  233. export function PageBlock({ children, title, description, className, blockId }: PageBlockProps) {
  234. return (
  235. <LocationWrapper blockId={blockId}>
  236. <Card className={cn('w-full', className)}>
  237. {title || description ? (
  238. <CardHeader>
  239. {title && <CardTitle>{title}</CardTitle>}
  240. {description && <CardDescription>{description}</CardDescription>}
  241. </CardHeader>
  242. ) : null}
  243. <CardContent className={cn(!title ? 'pt-6' : '')}>{children}</CardContent>
  244. </Card>
  245. </LocationWrapper>
  246. );
  247. }
  248. export function FullWidthPageBlock({
  249. children,
  250. className,
  251. blockId,
  252. }: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
  253. return (
  254. <LocationWrapper blockId={blockId}>
  255. <div className={cn('w-full', className)}>{children}</div>
  256. </LocationWrapper>
  257. );
  258. }
  259. export function CustomFieldsPageBlock({
  260. column,
  261. entityType,
  262. control,
  263. }: {
  264. column: 'main' | 'side';
  265. entityType: string;
  266. control: Control<any, any>;
  267. }) {
  268. const customFieldConfig = useCustomFieldConfig(entityType);
  269. if (!customFieldConfig || customFieldConfig.length === 0) {
  270. return null;
  271. }
  272. return (
  273. <PageBlock column={column} blockId="custom-fields">
  274. <CustomFieldsForm entityType={entityType} control={control} />
  275. </PageBlock>
  276. );
  277. }
  278. /**
  279. * @description
  280. * This compares the type of a React component to a given type.
  281. * It is safer than a simple `el === Component` check, as it also works in the context of
  282. * the Vite build where the component is not the same reference.
  283. */
  284. export function isOfType(el: unknown, type: React.FunctionComponent<any>): boolean {
  285. if (React.isValidElement(el)) {
  286. const elTypeName = typeof el.type === 'string' ? el.type : (el.type as React.FunctionComponent).name;
  287. return elTypeName === type.name;
  288. }
  289. return false;
  290. }