From 863b6e0a4e4bc09df4636035145d6831d21dc4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Fri, 19 Jan 2024 15:23:31 -0300 Subject: [PATCH] feat(components): add components --- .eslintrc.js | 8 - .gitignore | 3 + package.json | 47 +- rollup.config.js | 2 +- src/components/Cmdk.tsx | 96 + src/components/CopyClipboard.tsx | 78 + .../DataTable/DataTableColumnHeader.tsx | 60 + .../DataTable/DataTableFacetedFilter.tsx | 133 ++ .../DataTable/DataTablePagination.tsx | 80 + .../DataTable/DataTableRowActions.tsx | 37 + src/components/DataTable/DataTableToolbar.tsx | 59 + .../DataTable/DataTableViewOptions.tsx | 50 + .../DataTable/DynamicActionComponent.tsx | 106 ++ .../DataTable/SWRDataTable/index.tsx | 264 +++ .../DataTable/SWRDataTable/useTableState.tsx | 52 + src/components/DataTable/index.ts | 8 + src/components/FormBuilder/builder.tsx | 57 + src/components/FormBuilder/fields.tsx | 78 + .../FormBuilder/fields/CheckboxField.tsx | 43 + .../FormBuilder/fields/DatePickerInput.tsx | 79 + .../FormBuilder/fields/HiddenField.tsx | 17 + .../FormBuilder/fields/ImageUploadField.tsx | 117 ++ .../FormBuilder/fields/InputField.tsx | 63 + .../fields/MultiSelectCheckboxesField.tsx | 70 + .../FormBuilder/fields/RadioGroupField.tsx | 78 + .../FormBuilder/fields/SwitchField.tsx | 51 + .../FormBuilder/fields/TextAreaField.tsx | 61 + src/components/FormBuilder/fields/index.ts | 18 + .../fields/selects/ColorPickerField.tsx | 118 ++ .../fields/selects/MultiSelect.tsx | 164 ++ .../fields/selects/RadiusSelect.tsx | 64 + .../fields/selects/SearchableSelectField.tsx | 92 + .../fields/selects/SelectField.tsx | 58 + src/components/FormBuilder/index.ts | 3 + src/components/SectionTitle.tsx | 11 + src/components/TaggablePopover.tsx | 107 ++ src/components/ThemeToggle/context.tsx | 82 + src/components/ThemeToggle/index.tsx | 45 + src/components/index.ts | 7 + src/components/ui/AlertDialog.tsx | 139 ++ src/components/ui/Avatar.tsx | 47 + src/components/ui/Badge.tsx | 39 + src/components/ui/BlurredBackgroundLogo.tsx | 56 + src/components/ui/Button.tsx | 56 + src/components/ui/Calendar.tsx | 149 ++ src/components/ui/Card.tsx | 122 ++ src/components/ui/Checkbox.tsx | 27 + src/components/ui/Command.tsx | 149 ++ src/components/ui/Counter.tsx | 35 + src/components/ui/Dialog.tsx | 119 ++ src/components/ui/DropdownMenu.tsx | 195 ++ src/components/ui/Form.tsx | 216 +++ src/components/ui/Input.tsx | 21 + src/components/ui/Label.tsx | 23 + src/components/ui/Pagination.tsx | 41 + src/components/ui/Popover.tsx | 28 + src/components/ui/RadioGroup.tsx | 37 + src/components/ui/Select.tsx | 161 ++ src/components/ui/SelectInput.tsx | 32 + src/components/ui/Separator.tsx | 28 + src/components/ui/Switch.tsx | 26 + src/components/ui/Table.tsx | 120 ++ src/components/ui/Tabs.tsx | 52 + src/components/ui/Textarea.tsx | 20 + src/components/ui/Toast.tsx | 125 ++ src/components/ui/Toaster.tsx | 29 + src/components/{ => ui}/Tooltip.tsx | 0 src/components/ui/index.ts | 29 + src/hooks/useToast.tsx | 193 ++ src/index.ts | 2 +- src/lib/formatDate.ts | 7 + src/lib/serializeQuery.ts | 32 + tsconfig.json | 2 + yarn.lock | 1579 +++++++++++++++-- 74 files changed, 6363 insertions(+), 139 deletions(-) create mode 100644 src/components/Cmdk.tsx create mode 100644 src/components/CopyClipboard.tsx create mode 100644 src/components/DataTable/DataTableColumnHeader.tsx create mode 100644 src/components/DataTable/DataTableFacetedFilter.tsx create mode 100644 src/components/DataTable/DataTablePagination.tsx create mode 100644 src/components/DataTable/DataTableRowActions.tsx create mode 100644 src/components/DataTable/DataTableToolbar.tsx create mode 100644 src/components/DataTable/DataTableViewOptions.tsx create mode 100644 src/components/DataTable/DynamicActionComponent.tsx create mode 100644 src/components/DataTable/SWRDataTable/index.tsx create mode 100644 src/components/DataTable/SWRDataTable/useTableState.tsx create mode 100644 src/components/DataTable/index.ts create mode 100644 src/components/FormBuilder/builder.tsx create mode 100644 src/components/FormBuilder/fields.tsx create mode 100644 src/components/FormBuilder/fields/CheckboxField.tsx create mode 100644 src/components/FormBuilder/fields/DatePickerInput.tsx create mode 100644 src/components/FormBuilder/fields/HiddenField.tsx create mode 100644 src/components/FormBuilder/fields/ImageUploadField.tsx create mode 100644 src/components/FormBuilder/fields/InputField.tsx create mode 100644 src/components/FormBuilder/fields/MultiSelectCheckboxesField.tsx create mode 100644 src/components/FormBuilder/fields/RadioGroupField.tsx create mode 100644 src/components/FormBuilder/fields/SwitchField.tsx create mode 100644 src/components/FormBuilder/fields/TextAreaField.tsx create mode 100644 src/components/FormBuilder/fields/index.ts create mode 100644 src/components/FormBuilder/fields/selects/ColorPickerField.tsx create mode 100644 src/components/FormBuilder/fields/selects/MultiSelect.tsx create mode 100644 src/components/FormBuilder/fields/selects/RadiusSelect.tsx create mode 100644 src/components/FormBuilder/fields/selects/SearchableSelectField.tsx create mode 100644 src/components/FormBuilder/fields/selects/SelectField.tsx create mode 100644 src/components/FormBuilder/index.ts create mode 100644 src/components/SectionTitle.tsx create mode 100644 src/components/TaggablePopover.tsx create mode 100644 src/components/ThemeToggle/context.tsx create mode 100644 src/components/ThemeToggle/index.tsx create mode 100644 src/components/index.ts create mode 100644 src/components/ui/AlertDialog.tsx create mode 100644 src/components/ui/Avatar.tsx create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/BlurredBackgroundLogo.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Calendar.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/Checkbox.tsx create mode 100644 src/components/ui/Command.tsx create mode 100644 src/components/ui/Counter.tsx create mode 100644 src/components/ui/Dialog.tsx create mode 100644 src/components/ui/DropdownMenu.tsx create mode 100644 src/components/ui/Form.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/Label.tsx create mode 100644 src/components/ui/Pagination.tsx create mode 100644 src/components/ui/Popover.tsx create mode 100644 src/components/ui/RadioGroup.tsx create mode 100644 src/components/ui/Select.tsx create mode 100644 src/components/ui/SelectInput.tsx create mode 100644 src/components/ui/Separator.tsx create mode 100644 src/components/ui/Switch.tsx create mode 100644 src/components/ui/Table.tsx create mode 100644 src/components/ui/Tabs.tsx create mode 100644 src/components/ui/Textarea.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/components/ui/Toaster.tsx rename src/components/{ => ui}/Tooltip.tsx (100%) create mode 100644 src/components/ui/index.ts create mode 100644 src/hooks/useToast.tsx create mode 100644 src/lib/formatDate.ts create mode 100644 src/lib/serializeQuery.ts diff --git a/.eslintrc.js b/.eslintrc.js index e45717b..54e43ea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,14 +48,6 @@ module.exports = { "typescript-sort-keys/interface": "error", "typescript-sort-keys/string-enum": "error", "unused-imports/no-unused-imports": "error", - "prefer-arrow/prefer-arrow-functions": [ - "error", - { - disallowPrototype: true, - singleReturnOnly: true, - classPropertiesAllowed: false, - }, - ], "sort-class-members/sort-class-members": [ "error", { diff --git a/.gitignore b/.gitignore index 5b41965..ceb2f87 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ storybook-static !.yarn/releases !.yarn/sdks !.yarn/versions + +.yalc/* +yalc.lock diff --git a/package.json b/package.json index 743e3f9..a388c9d 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,9 @@ "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@types/node": "20.11.5", + "@types/quill": "^1", "@types/react": "18.2.48", + "@types/react-beautiful-dnd": "^13", "@types/react-dom": "18.2.18", "@types/react-test-renderer": "18.0.7", "@types/rollup-plugin-peer-deps-external": "^2", @@ -153,7 +155,8 @@ }, "peerDependencies": { "react": ">=18", - "react-dom": ">=18" + "react-dom": ">=18", + "react-router-dom": "^6.21.0" }, "resolutions": { "glob-parent": ">=5.1.2", @@ -166,10 +169,50 @@ }, "packageManager": "yarn@4.0.0", "dependencies": { + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@tanstack/react-table": "^8.11.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", - "tailwind-merge": "^2.2.0" + "cmdk": "^0.2.0", + "copy-to-clipboard": "^3.3.3", + "object-to-formdata": "^4.5.1", + "quill": "^1.3.7", + "quill-html-edit-button": "^2.2.13", + "react-beautiful-dnd": "^13.1.1", + "react-day-picker": "^8.10.0", + "react-hook-form": "^7.49.3", + "react-quill": "^2.0.0", + "react-router": "^6.21.1", + "react-router-dom": "^6.21.1", + "swr": "^2.2.4", + "tailwind-merge": "^2.2.0", + "zod": "^3.22.4" } } diff --git a/rollup.config.js b/rollup.config.js index f185392..74e0e39 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -26,7 +26,7 @@ export default [ format: "esm", }, ], - external: ["react", "react-dom"], + external: ["react", "react-dom", "react-router-dom"], plugins: [ peerDepsExternal(), resolve(), diff --git a/src/components/Cmdk.tsx b/src/components/Cmdk.tsx new file mode 100644 index 0000000..656a884 --- /dev/null +++ b/src/components/Cmdk.tsx @@ -0,0 +1,96 @@ +import { CircleIcon, FileIcon } from "@radix-ui/react-icons"; +import * as React from "react"; +import { useNavigate } from "react-router-dom"; +import { + Button, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; + +export function CommandMenu({ commands, ...props }) { + const navigate = useNavigate(); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + const down = (e) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + // eslint-disable-next-line no-shadow + setOpen((open) => !open); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + const runCommand = React.useCallback((command) => { + setOpen(false); + command(); + }, []); + + return ( + <> + + + + + No results found. + + {commands.mainNav + .filter((navitem) => !navitem.external) + .map((navItem) => ( + { + runCommand(() => navigate(navItem.href)); + }} + > + + {navItem.title} + + ))} + + + {commands.sidebarNav?.map((group) => ( + + {group.items.map((navItem) => ( + { + runCommand(() => navigate(navItem.href)); + }} + > +
+ +
+ {navItem.title} +
+ ))} +
+ ))} +
+
+ + ); +} diff --git a/src/components/CopyClipboard.tsx b/src/components/CopyClipboard.tsx new file mode 100644 index 0000000..ad42e6f --- /dev/null +++ b/src/components/CopyClipboard.tsx @@ -0,0 +1,78 @@ +/* eslint-disable consistent-return */ +import { ClipboardCopyIcon } from "@radix-ui/react-icons"; +import copy from "copy-to-clipboard"; +import React, { PropsWithChildren, useCallback, useLayoutEffect } from "react"; +import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/useToast"; + +export function ClickToCopy({ + text, + className, + children, +}: PropsWithChildren<{ + className?: string; + text: string; +}>) { + const [copied, setCopied] = React.useState(false); + const { toast } = useToast(); + + useLayoutEffect(() => { + if (copied) { + const timeout = setTimeout(() => { + setCopied(false); + }, 2000); + + toast({ + title: "Copied to clipboard", + variant: "default", + }); + + return () => clearTimeout(timeout); + } + }, [copied]); + + return ( +
+ setCopied(true)} + className={cn("flex flex-row items-center", className)} + > + <> + {children} + + + +
+ ); +} + +function CopyToClipboard({ text, className, onCopy, children }) { + const [copied, setCopied] = React.useState(false); + + useLayoutEffect(() => { + if (copied) { + const timeout = setTimeout(() => { + setCopied(false); + }, 1000); + + return () => clearTimeout(timeout); + } + }, [copied]); + + const handleCopy = useCallback(() => { + copy(text); + onCopy?.(); + }, [text, onCopy]); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ {children} +
+ ); +} diff --git a/src/components/DataTable/DataTableColumnHeader.tsx b/src/components/DataTable/DataTableColumnHeader.tsx new file mode 100644 index 0000000..4b9a82c --- /dev/null +++ b/src/components/DataTable/DataTableColumnHeader.tsx @@ -0,0 +1,60 @@ +import { + ArrowDownIcon, + ArrowUpIcon, + CaretSortIcon, + EyeNoneIcon, +} from "@radix-ui/react-icons"; +import React from "react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; + +export function DataTableColumnHeader({ column, title, className }) { + if (!column.getCanSort()) { + return
{title}
; + } + + return ( +
+ + + + + + column.toggleSorting(false)}> + + Asc + + column.toggleSorting(true)}> + + Desc + + + column.toggleVisibility(false)}> + + Hide + + + +
+ ); +} diff --git a/src/components/DataTable/DataTableFacetedFilter.tsx b/src/components/DataTable/DataTableFacetedFilter.tsx new file mode 100644 index 0000000..fd85e04 --- /dev/null +++ b/src/components/DataTable/DataTableFacetedFilter.tsx @@ -0,0 +1,133 @@ +import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { + Badge, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + Popover, + PopoverContent, + PopoverTrigger, + Separator, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; + +export function DataTableFacetedFilter({ column, title, options }) { + const facets = column?.getFacetedUniqueValues(); + const selectedValues = new Set(column?.getFilterValue()); + + return ( + + + + + + + + + No results found.{" "} + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + if (isSelected) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ); + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx new file mode 100644 index 0000000..874529d --- /dev/null +++ b/src/components/DataTable/DataTablePagination.tsx @@ -0,0 +1,80 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, Select } from "@/components/ui"; + +export function DataTablePagination({ table }) { + return ( +
+
+
+

Rows per page

+ { + table.setPageSize(Number(value)); + }} + > + + + + + {[10, 20, 30, 40, 50].map((pageSize) => ( + + {pageSize} + + ))} + + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/DataTable/DataTableRowActions.tsx b/src/components/DataTable/DataTableRowActions.tsx new file mode 100644 index 0000000..8b9881d --- /dev/null +++ b/src/components/DataTable/DataTableRowActions.tsx @@ -0,0 +1,37 @@ +/* eslint-disable camelcase */ +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui"; + +import { DynamicActionComponent } from "./DynamicActionComponent"; + +export const DataTableRowActions = ({ row, column }) => ( + + + + + + {/* TODO: here we'd need to filter actions that the user cannot perform, or ideally do this in the BE */} + {column.columnDef.actions + .filter(({ condition_key, condition_value }) => { + if (!condition_key && !condition_value) return true; + + return row.original?.[condition_key] === condition_value; + }) + .map((action) => ( + + ))} + + +); diff --git a/src/components/DataTable/DataTableToolbar.tsx b/src/components/DataTable/DataTableToolbar.tsx new file mode 100644 index 0000000..3e315bc --- /dev/null +++ b/src/components/DataTable/DataTableToolbar.tsx @@ -0,0 +1,59 @@ +import { Cross2Icon } from "@radix-ui/react-icons"; +import React from "react"; +import { Button, Input } from "@/components/ui"; + +import { DataTableFacetedFilter } from "./DataTableFacetedFilter"; +import { DataTableViewOptions } from "./DataTableViewOptions"; + +export function DataTableToolbar({ + table, + filters, + action, + searchKey = "name", +}) { + if (!table || !filters) { + return null; + } + + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ + table.getColumn(searchKey)?.setFilterValue(event.target.value) + } + className="h-8 w-[150px] lg:w-[250px] dark:border-2" + /> + {filters.map( + (filter) => + table.getColumn(filter.value) && ( + + ) + )} + {isFiltered && ( + + )} +
+
+ {action} + +
+
+ ); +} diff --git a/src/components/DataTable/DataTableViewOptions.tsx b/src/components/DataTable/DataTableViewOptions.tsx new file mode 100644 index 0000000..116a85b --- /dev/null +++ b/src/components/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,50 @@ +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import { MixerHorizontalIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui"; + +export function DataTableViewOptions({ table }) { + return ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + !column.columnDef.hide && + column.getCanHide() + ) + .map((column) => ( + column.toggleVisibility(!!value)} + > + {column?.columnDef?.title || column.id} + + ))} + + + ); +} diff --git a/src/components/DataTable/DynamicActionComponent.tsx b/src/components/DataTable/DynamicActionComponent.tsx new file mode 100644 index 0000000..b3056b0 --- /dev/null +++ b/src/components/DataTable/DynamicActionComponent.tsx @@ -0,0 +1,106 @@ +/* eslint-disable no-case-declarations */ +import React from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + Form, +} from "@/components/ui"; +import { ClickToCopy } from "@/components/CopyClipboard"; + +export const DynamicActionComponent = ({ action, row }) => { + const navigate = useNavigate(); + + const handleLinkAction = () => { + navigate(action.url_path.replace("RESOURCE_ID", row.original.id)); + }; + + const renderActionButton = () => { + switch (action.type) { + case "link": + return ( + + ); + case "copy": + const key = action.content?.key; + const value = row.original?.[key]; + + if (!value) return null; + + return ( + + ); + case "form": + return ; + default: + return null; + } + }; + + return renderActionButton(); +}; + +const ActionForm = ({ action, row }) => { + const form = useForm({}); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + + // TODO: the styles here are a bit off, but it's a start + return ( + <> + {action?.trigger_confirmation && ( + + + + + + + Are you sure? + + This action cannot be undone. + + + + Cancel +
+ {action.method === "delete" && ( + + )} + +
+
+
+
+ )} + {!action?.trigger_confirmation && ( +
+ {action.method === "delete" && ( + + )} + +
+ )} + + ); +}; diff --git a/src/components/DataTable/SWRDataTable/index.tsx b/src/components/DataTable/SWRDataTable/index.tsx new file mode 100644 index 0000000..0a67fce --- /dev/null +++ b/src/components/DataTable/SWRDataTable/index.tsx @@ -0,0 +1,264 @@ +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; +import { + Badge, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import { SectionTitle } from "@/components/SectionTitle"; +import { formatDate } from "@/lib/formatDate"; +import { serializeQuery } from "@/lib/serializeQuery"; +import { useTableState } from "./useTableState"; + +import { DataTableColumnHeader } from "@/components/DataTable/DataTableColumnHeader"; +import { DataTablePagination } from "@/components/DataTable/DataTablePagination"; +import { DataTableRowActions } from "@/components/DataTable/DataTableRowActions"; +import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar"; + +function formatRequestParams(originalObj) { + return { + ...originalObj, + columnFilters: originalObj.columnFilters.reduce((acc, filter) => { + acc[filter.id] = filter.value; + return acc; + }, {}), + }; +} + +const fetcher = async ([url, paramsObject]) => { + const formattedParams = formatRequestParams(paramsObject); + // @ts-expect-error TS(2554) FIXME: Expected 2 arguments, but got 1. + const params = serializeQuery(formattedParams); + const response = await fetch(`${url}?${params}`, { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error("Failed to fetch."); + } + return response.json(); +}; + +const renderCell = ({ filters, column, row }) => { + const value = row.getValue(column.columnDef.accessorKey); + + switch (column.columnDef.type) { + case "text": + return
{value}
; + case "badge": + // TODO: needs to be refactored + switch (value) { + case "draft": + return Draft; + case "scheduled": + return Scheduled; + case "active": + return Active; + case "ended": + return Ended; + default: + if (column.columnDef.accessorKey !== "is_active") { + return {value}; + } + + // eslint-disable-next-line no-case-declarations + const status = filters[0].options.find( + // eslint-disable-next-line no-shadow + (status) => status.value === row.getValue("is_active") + ); + + if (!status) { + return null; + } + + return ( + + {status.label} + + ); + } + case "date": + return
{formatDate(value)}
; + case "number": + return
{value}
; + case "actions": + return ; + case "image": + return ( + {row.getValue("name")} + ); + + default: + return null; + } +}; + +const defaultFilterFn = (row, id, filterValue) => + row.getValue(id).includes(filterValue); + +const buildColumns = (columnsConfig, filters) => { + if (!columnsConfig) return []; + + return columnsConfig.map((columnConfig) => ({ + ...columnConfig, + header: ({ column }) => ( + // @ts-expect-error TS(2741) FIXME: Property 'className' is missing in type '{ column:... Remove this comment to see the full error message + + ), + cell: (rest) => renderCell({ filters, ...rest }), + filterFn: columnConfig.filterable ? defaultFilterFn : null, + })); +}; + +export function SWRDataTable({ + fetchPath, + searchKey = "name", + defaultParams = {}, + hasDetails = false, + action, +}) { + const navigate = useNavigate(); + const { + pagination, + rowSelection, + columnVisibility, + columnFilters, + sorting, + setPagination, + setRowSelection, + setColumnVisibility, + setColumnFilters, + setSorting, + } = useTableState(defaultParams); + + const { pageIndex, pageSize } = pagination; + + const { data, error } = useSWR( + [fetchPath, { pageIndex, pageSize, sorting, columnFilters }], + fetcher, + { + keepPreviousData: true, + } + ); + + const columns = buildColumns(data?.columns, data?.filters); + const filters = data?.filters; + + const table = useReactTable({ + data: data?.data ?? [], + pageCount: Math.ceil((data?.total ?? 0) / pageSize), + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + onPaginationChange: setPagination, + manualPagination: true, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + if (error) { + return ( +
+
+ Something went wrong! +

{error.message}

+
+
+ ); + } + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + if (hasDetails) { + // @ts-expect-error TS(2339) FIXME: Property 'id' does not exist on type 'unknown'. + navigate(`${row.original.id}`); + } + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/src/components/DataTable/SWRDataTable/useTableState.tsx b/src/components/DataTable/SWRDataTable/useTableState.tsx new file mode 100644 index 0000000..0f1bf59 --- /dev/null +++ b/src/components/DataTable/SWRDataTable/useTableState.tsx @@ -0,0 +1,52 @@ +import { useMemo, useState } from "react"; + +export function useTableState(initialState = {}) { + const [pagination, setPagination] = useState( + // @ts-expect-error TS(2339) FIXME: Property 'pagination' does not exist on type '{}'. + initialState.pagination || { pageIndex: 0, pageSize: 10 } + ); + const [rowSelection, setRowSelection] = useState( + // @ts-expect-error TS(2339) FIXME: Property 'rowSelection' does not exist on type '{}... Remove this comment to see the full error message + initialState.rowSelection || {} + ); + const [columnVisibility, setColumnVisibility] = useState( + // @ts-expect-error TS(2339) FIXME: Property 'columnVisibility' does not exist on type... Remove this comment to see the full error message + initialState.columnVisibility || [] + ); + const [columnFilters, setColumnFilters] = useState( + // @ts-expect-error TS(2339) FIXME: Property 'columnFilters' does not exist on type '{... Remove this comment to see the full error message + initialState.columnFilters || [] + ); + // @ts-expect-error TS(2339) FIXME: Property 'sorting' does not exist on type '{}'. + const [sorting, setSorting] = useState(initialState.sorting || []); + + const state = useMemo( + () => ({ + pagination, + rowSelection, + columnVisibility, + columnFilters, + sorting, + }), + [pagination, rowSelection, columnVisibility, columnFilters, sorting] + ); + + const handlers = useMemo( + () => ({ + setPagination, + setRowSelection, + setColumnVisibility, + setColumnFilters, + setSorting, + }), + [ + setPagination, + setRowSelection, + setColumnVisibility, + setColumnFilters, + setSorting, + ] + ); + + return { ...state, ...handlers }; +} diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 0000000..212ddb0 --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1,8 @@ +export * from "./DataTableColumnHeader"; +export * from "./DataTableFacetedFilter"; +export * from "./DataTablePagination"; +export * from "./DataTableRowActions"; +export * from "./DataTableToolbar"; +export * from "./DataTableViewOptions"; +export * from "./DynamicActionComponent"; +export * from "./SWRDataTable"; diff --git a/src/components/FormBuilder/builder.tsx b/src/components/FormBuilder/builder.tsx new file mode 100644 index 0000000..d9fdc53 --- /dev/null +++ b/src/components/FormBuilder/builder.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +// import { FieldArray } from "./fields/ArrayField"; +import { CheckboxField } from "./fields/CheckboxField"; +import { DatePickerInput } from "./fields/DatePickerInput"; +import { HiddenField } from "./fields/HiddenField"; +import { ImageUploadField } from "./fields/ImageUploadField"; +import { InputField } from "./fields/InputField"; +import { MultiSelectCheckboxes } from "./fields/MultiSelectCheckboxesField"; + +import { RadioGroupField } from "./fields/RadioGroupField"; +// import { RichTextEditorField } from "./fields/RichTextEditorField"; +import { ColorPickerField } from "./fields/selects/ColorPickerField"; +import { MultiSelect } from "./fields/selects/MultiSelect"; +import { RadiusSelect } from "./fields/selects/RadiusSelect"; +import { SearchableSelectField } from "./fields/selects/SearchableSelectField"; +import { SelectField } from "./fields/selects/SelectField"; +import { SwitchField } from "./fields/SwitchField"; +import { TextAreaField } from "./fields/TextAreaField"; + +export const fieldComponents = { + input: InputField, + checkbox: CheckboxField, + radio_item: RadioGroupField, + textarea: TextAreaField, + date: DatePickerInput, + datetime: DatePickerInput, + image: ImageUploadField, + switch: SwitchField, + select: SelectField, + multi_select: MultiSelect, + // field_array: FieldArray, + hidden: HiddenField, + // wysiwyg: RichTextEditorField, + multi_select_checkbox: MultiSelectCheckboxes, + color_picker: ColorPickerField, + searchable_select: SearchableSelectField, + radius_select: RadiusSelect, +} as const; + +// @ts-ignore +export function buildForm(fields, form, index = 0) { + // @ts-ignore + const formElements = fields.map((field) => { + // @ts-ignore + const FieldComponent = field?.component || fieldComponents[field.type]; + if (!FieldComponent) { + throw new Error(`Invalid field type: ${field.type}`); + } + + const name = field.name.replace("RESOURCE_ID", index); + + return ; + }); + + return formElements; +} diff --git a/src/components/FormBuilder/fields.tsx b/src/components/FormBuilder/fields.tsx new file mode 100644 index 0000000..990bdfb --- /dev/null +++ b/src/components/FormBuilder/fields.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { FieldValues, UseFormReturn } from "react-hook-form"; + +export interface BaseField { + conditions?: Array<{ + name: string; + value: string | Array; + }>; + defaultValue?: string; + description: string; + disabled?: boolean | ((data: FieldValues) => boolean); + index?: number; + label: string; + name: string; + placeholder?: string; + required?: boolean; + type: string; + value: string; + // Other common properties... +} + +export interface CommonFieldProps { + field: T; + form: UseFormReturn; +} + +export function withConditional( + Component: React.ComponentType> +) { + return (props: CommonFieldProps) => { + const { form, field } = props; + + const conditions = Array.isArray(field.conditions) + ? field.conditions + : null; + + if (!conditions) return ; + + const watchedFields = conditions.map((condition) => + condition + ? form.watch(condition.name.replace("RESOURCE_ID", String(field.index))) + : null + ); + + // determine if all conditions are satisfied + const shouldRender = conditions.every((condition, index) => { + if (!condition) return true; // No condition means always render + + const watchField = watchedFields[index]; + if (condition.value instanceof Array) { + return condition.value.includes(watchField); + } + return watchField === condition.value; + }); + + if (!shouldRender) return null; + + return ; + }; +} + +export const parseFields = (fields, index) => { + const updatedFields = fields.map((field) => { + if (field.type === "field_array") { + return { + ...field, + name: field.name.replace("RESOURCE_ID", index), + fields: field.fields.map((f) => ({ + ...f, + name: f.name.replace("RESOURCE_ID", index), + })), + }; + } + return { ...field, name: field.name.replace("RESOURCE_ID", index) }; + }); + + return updatedFields; +}; diff --git a/src/components/FormBuilder/fields/CheckboxField.tsx b/src/components/FormBuilder/fields/CheckboxField.tsx new file mode 100644 index 0000000..b583467 --- /dev/null +++ b/src/components/FormBuilder/fields/CheckboxField.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Checkbox } from "@/components/ui/Checkbox"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/Form"; + +import { BaseField, withConditional } from "../fields"; + +export interface CheckboxFieldProps extends BaseField { + type: "checkbox"; +} + +export const CheckboxField = withConditional( + ({ form, field }) => ( + ( + + + + + +
+ {field.label} + {field.description} +
+
+ )} + /> + ) +); diff --git a/src/components/FormBuilder/fields/DatePickerInput.tsx b/src/components/FormBuilder/fields/DatePickerInput.tsx new file mode 100644 index 0000000..ddc1b65 --- /dev/null +++ b/src/components/FormBuilder/fields/DatePickerInput.tsx @@ -0,0 +1,79 @@ +import { CalendarIcon } from "@radix-ui/react-icons"; +import { format } from "date-fns"; +import React from "react"; +import { Button } from "@/components/ui/Button"; +import { Calendar } from "@/components/ui/Calendar"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/Popover"; +import { cn } from "@/lib/utils"; + +import { BaseField, withConditional } from "../fields"; + +export interface DatePickerInputProps extends BaseField { + type: "date" | "datetime"; +} + +export const DatePickerInput = withConditional( + ({ form, field }) => { + const isDatetime = field.type === "datetime"; + + return ( + ( + + {field.label} + + + + + + + + {/* @ts-expect-error TS(2739) FIXME: Type '{ mode: string; selected: any; onSelect: any... Remove this comment to see the full error message */} + + + + {field.description} + + + )} + /> + ); + } +); diff --git a/src/components/FormBuilder/fields/HiddenField.tsx b/src/components/FormBuilder/fields/HiddenField.tsx new file mode 100644 index 0000000..a805855 --- /dev/null +++ b/src/components/FormBuilder/fields/HiddenField.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { FormControl, FormField } from "@/components/ui/Form"; + +import { withConditional } from "../fields"; + +export const HiddenField = withConditional(({ form, field }) => ( + ( + + + + )} + /> +)); diff --git a/src/components/FormBuilder/fields/ImageUploadField.tsx b/src/components/FormBuilder/fields/ImageUploadField.tsx new file mode 100644 index 0000000..2706f7e --- /dev/null +++ b/src/components/FormBuilder/fields/ImageUploadField.tsx @@ -0,0 +1,117 @@ +import { cva } from "class-variance-authority"; +import React, { useCallback, useEffect } from "react"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; +import { cn } from "@/lib/utils"; + +import { BaseField, withConditional } from "../fields"; + +const imageUploadVariants = cva("w-full", { + variants: { + size: { + small: "size-24 p-0", + medium: "size-48 p-0", + large: "size-80 p-0", + }, + }, + defaultVariants: { + size: "medium", + }, +}); + +export interface ImageUploadFieldProps extends BaseField { + style?: { + size?: "small" | "medium" | "large"; + }; + type: "image"; +} + +export const ImageUploadField = withConditional( + ({ form, field }) => { + const [imagePreview, setImagePreview] = React.useState(""); + const hiddenFileInput = React.useRef(null); + + useEffect(() => { + const value = form.getValues(field.name); + if (!value) return; + + setImagePreview(value); + }, []); + + const onUpload = useCallback((event) => { + if (!event.target.files?.length) return; + + const file = event.target.files[0]; + setImagePreview(URL.createObjectURL(file?.url ? file.url : file)); + form.setValue(field.name, file); + }, []); + + const handleClick = () => { + if (hiddenFileInput.current) hiddenFileInput.current.click(); + }; + + return ( + ( + + {field.label} + {field.description} + + +
+ + +
+
+ +
+ )} + /> + ); + } +); diff --git a/src/components/FormBuilder/fields/InputField.tsx b/src/components/FormBuilder/fields/InputField.tsx new file mode 100644 index 0000000..d65456a --- /dev/null +++ b/src/components/FormBuilder/fields/InputField.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { z } from "zod"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; +import { Input } from "@/components/ui/Input"; + +import { BaseField, withConditional } from "../fields"; + +export interface InputFieldProps extends BaseField { + length?: { + maximum?: number; + minimum: number; // undefined or number + }; + mode: "text" | "number"; + type: "input"; +} + +export const InputField = withConditional( + ({ form, field }) => { + const validator = field.mode === "number" ? z.coerce.number() : z.string(); + + validator + .min(field.length?.minimum || 0) + .max(field.length?.maximum || Infinity); + + return ( + + validator.safeParse(value).success || + `The value must be between ${field.length?.minimum} and ${field.length?.maximum} long.`, + } + : undefined + } + render={({ field: formField }) => ( + + {field.label} + {field.description} + + + + + + )} + /> + ); + } +); diff --git a/src/components/FormBuilder/fields/MultiSelectCheckboxesField.tsx b/src/components/FormBuilder/fields/MultiSelectCheckboxesField.tsx new file mode 100644 index 0000000..795b82a --- /dev/null +++ b/src/components/FormBuilder/fields/MultiSelectCheckboxesField.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { Checkbox } from "@/components/ui/Checkbox"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; + +import { withConditional } from "../fields"; +import { SelectFieldProps } from "./selects/SelectField"; + +export interface MultiSelectCheckboxesField extends SelectFieldProps { + type: "multi_select_checkbox"; +} + +export const MultiSelectCheckboxes = + withConditional(({ form, field }) => ( + ( + +
+ {field.label} + {field.description} +
+
+ {field.options.map((item) => ( + ( + + + + checked + ? formField.onChange([ + ...formField.value, + item.value, + ]) + : formField.onChange( + formField.value?.filter( + (value) => value !== item.value + ) + ) + } + /> + + {item.label} + + )} + /> + ))} +
+ +
+ )} + /> + )); diff --git a/src/components/FormBuilder/fields/RadioGroupField.tsx b/src/components/FormBuilder/fields/RadioGroupField.tsx new file mode 100644 index 0000000..0b26a4d --- /dev/null +++ b/src/components/FormBuilder/fields/RadioGroupField.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; +import { Label } from "@/components/ui/Label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/RadioGroup"; + +import { BaseField, withConditional } from "../fields"; + +export interface RadioGroupFieldProps extends BaseField { + sections: Array<{ + description: string; + label: string; + options: Array<{ + label: string; + value: string; + }>; + }>; +} + +export const RadioGroupField = withConditional( + ({ form, field }) => ( + // TODO: handle this case + // const hasSections = field.sections.length > 0; + // if (!hasSections) return; + + ( + + {field.label} + {field.description} + + +
+ {field.sections.map((section) => ( +
+ +

+ {section.description} +

+
+ {section.options.map((option) => ( + + + + + + {option.label} + + + ))} +
+
+ ))} +
+
+
+ +
+ )} + /> + ) +); diff --git a/src/components/FormBuilder/fields/SwitchField.tsx b/src/components/FormBuilder/fields/SwitchField.tsx new file mode 100644 index 0000000..6852800 --- /dev/null +++ b/src/components/FormBuilder/fields/SwitchField.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/Form"; +import { Switch } from "@/components/ui/Switch"; + +import { BaseField, withConditional } from "../fields"; + +export interface SwitchFieldProps extends BaseField { + type: "switch"; +} + +export const SwitchField = withConditional( + ({ form, field }) => { + const disabled = + typeof field.disabled === "function" + ? field.disabled(form.getValues()) + : field.disabled; + + return ( + ( + + +
+ {field.label} + {field.description} +
+ + + +
+ )} + /> + ); + } +); diff --git a/src/components/FormBuilder/fields/TextAreaField.tsx b/src/components/FormBuilder/fields/TextAreaField.tsx new file mode 100644 index 0000000..8d8fcb0 --- /dev/null +++ b/src/components/FormBuilder/fields/TextAreaField.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { z } from "zod"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/Form"; +import { Textarea } from "@/components/ui/Textarea"; + +import { BaseField, withConditional } from "../fields"; + +export interface TextAreaFieldProps extends BaseField { + length?: { + maximum?: number; + minimum: number; // undefined or number + }; + type: "textarea"; +} + +export const TextAreaField = withConditional( + ({ form, field }) => { + const stringSchema = z + .string() + .min(field.length?.minimum || 0) + .max(field.length?.maximum || Infinity); + + return ( + + stringSchema.safeParse(value).success || + `The value must be between ${field.length?.minimum} and ${field.length?.maximum} long.`, + } + : undefined + } + render={({ field: formField }) => ( + + {field.label} + {field.description} + +