From 033f78d29aed1b31c691a8e07ecfebb2eb8efa84 Mon Sep 17 00:00:00 2001 From: duchuy-wins Date: Thu, 13 Jun 2024 09:42:48 +0700 Subject: [PATCH 1/3] Feat: permission group --- package-lock.json | 107 +--- package.json | 3 +- .../dashboard/chihois/_components/columns.tsx | 6 +- .../dashboard/roles/_components/columns.tsx | 24 +- .../dashboard/roles/_components/role-form.tsx | 26 +- .../roles/_components/roles-table.tsx | 2 +- src/components/form/antd-image-upload.tsx | 38 +- src/components/ui/command.tsx | 155 ++++++ src/components/ui/dialog.tsx | 122 +++++ src/components/ui/multi-selector.tsx | 498 ++++++++++++++++++ src/lib/axios.ts | 22 +- src/lib/constants.tsx | 10 +- src/lib/hooks/use-sorting.ts | 2 +- src/lib/utils.ts | 11 + src/types/default-type.ts | 8 + src/types/employee.ts | 29 +- src/types/permissionGroup.ts | 26 + src/types/request-params.ts | 2 +- src/types/role.ts | 49 +- 19 files changed, 982 insertions(+), 158 deletions(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/multi-selector.tsx create mode 100644 src/types/default-type.ts create mode 100644 src/types/permissionGroup.ts diff --git a/package-lock.json b/package-lock.json index 9299d34..7d1d81a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,6 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", - "@radix-ui/react-progress": "^1.0.3", - "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", @@ -33,6 +31,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dayjs": "^1.11.11", "focus-trap-react": "^10.2.3", @@ -45,7 +44,6 @@ "react-aria": "^3.33.1", "react-day-picker": "^8.10.1", "react-dom": "^18", - "react-dropzone": "^14.2.3", "react-hook-form": "^7.51.5", "react-stately": "^3.31.1", "sonner": "^1.4.41", @@ -1318,30 +1316,6 @@ } } }, - "node_modules/@radix-ui/react-progress": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.0.3.tgz", - "integrity": "sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", @@ -1373,37 +1347,6 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", - "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/number": "1.0.1", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", @@ -4125,14 +4068,6 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, - "node_modules/attr-accept": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", - "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", - "engines": { - "node": ">=4" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4447,6 +4382,19 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5610,17 +5558,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-selector": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", - "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", - "dependencies": { - "tslib": "^2.4.0" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8736,22 +8673,6 @@ "react": "^18.2.0" } }, - "node_modules/react-dropzone": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", - "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", - "dependencies": { - "attr-accept": "^2.2.2", - "file-selector": "^0.6.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "react": ">= 16.8 || 18.0.0" - } - }, "node_modules/react-hook-form": { "version": "7.51.5", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.5.tgz", diff --git a/package.json b/package.json index e3a1e4a..e66b553 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dayjs": "^1.11.11", "focus-trap-react": "^10.2.3", @@ -81,4 +82,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/app/dashboard/chihois/_components/columns.tsx b/src/app/dashboard/chihois/_components/columns.tsx index 6ef851e..8495416 100644 --- a/src/app/dashboard/chihois/_components/columns.tsx +++ b/src/app/dashboard/chihois/_components/columns.tsx @@ -64,12 +64,12 @@ const defaultColumns: ColumnDef[] = [ - - - + + + ); diff --git a/src/app/dashboard/roles/_components/columns.tsx b/src/app/dashboard/roles/_components/columns.tsx index 629ef23..83a066f 100644 --- a/src/app/dashboard/roles/_components/columns.tsx +++ b/src/app/dashboard/roles/_components/columns.tsx @@ -13,11 +13,13 @@ import { import { MoreHorizontal } from "lucide-react"; import { Role, roleSchema } from "@/types/role"; import { Checkbox } from "@/components/ui/checkbox"; -import { mergeArraysById } from "@/lib/utils"; +import { mergeArraysById, snakeToHumanReadable } from "@/lib/utils"; import DeleteRoleForm from "@/app/dashboard/roles/_components/delete-role-form"; import DropDownModalWrapper from "@/components/dropdown/dropdown-modal-wrapper"; import RoleForm from "@/app/dashboard/roles/_components/role-form"; import { dynamicColumns } from "@/components/table/dynamic-column"; +import { DataTableColumnHeader } from "@/components/table/data-table-column-header"; +import { PermissionGroup } from "@/types/permissionGroup"; const defaultColumns: ColumnDef[] = [ { @@ -72,6 +74,24 @@ const defaultColumns: ColumnDef[] = [ ]; // Add, or override stuff here by specify id -const updates: ColumnDef[] = []; +const updates: ColumnDef[] = [ + { + id: "permission_groups", + accessorKey: "permission_groups", + header: ({ column }) => , + cell: ({ row }) => { + const role = row.original; + const permissionGroups = role.permission_groups; + return ( + permissionGroups && + permissionGroups.he_thong && + permissionGroups.he_thong.length > 0 && + permissionGroups?.he_thong?.map((permission) => { + return
{permission.display_name}
; + }) + ); + }, + }, +]; export const columns = mergeArraysById(defaultColumns, updates); diff --git a/src/app/dashboard/roles/_components/role-form.tsx b/src/app/dashboard/roles/_components/role-form.tsx index 0b11079..9cc4f94 100644 --- a/src/app/dashboard/roles/_components/role-form.tsx +++ b/src/app/dashboard/roles/_components/role-form.tsx @@ -1,14 +1,14 @@ "use client"; -import { Role, roleSchema } from "@/types/role"; +import { PermissionID, Role, roleSchema } from "@/types/role"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import ModalCard from "@/components/form/modal-card"; import ReactHookForm from "@/components/form/react-hook-form"; import { useCreateRole, useEditRole } from "@/services/role-service"; import DynamicFormFields from "@/components/form/dynamic-form-fields"; -import { PasswordInput } from "@/components/ui/toggle-able-password"; +import MultipleSelector, { Option } from "@/components/ui/multi-selector"; -export default function RoleForm({ type, initialData = {} }: { type: "Create" | "Edit"; initialData?: any }) { +export default function RoleForm({ type, initialData = {} }: { type: "Create" | "Edit"; initialData?: Role }) { const { mutate: mutateCreate, isPending: isPendingCreate, isSuccess: isSuccessCreate } = useCreateRole(); const { mutate: mutateEdit, isPending: isPendingEdit, isSuccess: isSuccessEdit } = useEditRole(); @@ -16,12 +16,20 @@ export default function RoleForm({ type, initialData = {} }: { type: "Create" | resolver: zodResolver(roleSchema), defaultValues: initialData, }); - const handleSubmit = (values: Role) => { + // Handle permissions_id format: from [{label: 'test', value: '1'}] to ['1', '2'] + const permissionIds = values.permission_ids as Option[]; + values.permission_ids = permissionIds?.length > 0 && permissionIds.map(({ value }) => value); + if (type === "Create") mutateCreate(values); if (type === "Edit") mutateEdit(values); }; + const defaultOptions = Object.entries(PermissionID).map(([key, value]) => ({ + value: value as string, + label: key as string, + })); + return ( , + permission_ids: ( + no results found.

+ } + /> + ), }} /> diff --git a/src/app/dashboard/roles/_components/roles-table.tsx b/src/app/dashboard/roles/_components/roles-table.tsx index 655cb01..d0d610e 100644 --- a/src/app/dashboard/roles/_components/roles-table.tsx +++ b/src/app/dashboard/roles/_components/roles-table.tsx @@ -47,5 +47,5 @@ export function RolesTable({ columns, initialData, total }: DataT debugTable: process.env.NODE_ENV === "development", }); - return ; + return ; } diff --git a/src/components/form/antd-image-upload.tsx b/src/components/form/antd-image-upload.tsx index 218a67d..c7bc446 100644 --- a/src/components/form/antd-image-upload.tsx +++ b/src/components/form/antd-image-upload.tsx @@ -1,15 +1,16 @@ -import { FC } from "react"; import { InboxOutlined } from "@ant-design/icons"; -import type { UploadProps } from "antd"; import { message, Upload } from "antd"; +import { AxiosRequestHeaders } from "axios"; +import { useSession } from "next-auth/react"; const { Dragger } = Upload; -const props: UploadProps = { - name: "file", - multiple: true, - action: "https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload", - onChange(info) { +export default function ImageUpload({ onUpload }: { onUpload: any }) { + const session = useSession(); + + const handleSubmit = () => {}; + + const handleOnChange = (info) => { const { status } = info.file; if (status !== "uploading") { console.log(info.file, info.fileList); @@ -19,15 +20,26 @@ const props: UploadProps = { } else if (status === "error") { message.error(`${info.file.name} file upload failed.`); } - }, - onDrop(e) { + }; + + const handleOnDrop = (e) => { console.log("Dropped files", e.dataTransfer.files); - }, -}; + }; -export default function ImageUpload({ onUpload }: { onUpload: any }) { return ( - + false} // return false so that antd doesn't upload the picture right away + >

diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..007798a --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..01ff19c --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/multi-selector.tsx b/src/components/ui/multi-selector.tsx new file mode 100644 index 0000000..b138da1 --- /dev/null +++ b/src/components/ui/multi-selector.tsx @@ -0,0 +1,498 @@ +"use client"; + +import * as React from "react"; +import { forwardRef, useEffect } from "react"; +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; + +export interface Option { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface MultipleSelectorProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value)); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [key, value] of Object.entries(groupOption)) { + if (value.some((option) => targetOption.find((p) => p.value === option.value))) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef>( + ({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); + }, +); + +CommandEmpty.displayName = "CommandEmpty"; + +const MultipleSelector = React.forwardRef( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState(transToGroupOption(arrayDefaultOptions, groupBy)); + const [inputValue, setInputValue] = React.useState(""); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef.current?.focus(), + }), + [selected], + ); + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo(() => removePickedOption(options, selected), [options, selected]); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn("h-auto overflow-visible bg-transparent", commandProps?.className)} + shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + setOpen(false); + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder} + className={cn( + "flex-1 bg-transparent outline-none placeholder:text-muted-foreground", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> +
+
+
+ {open && ( + + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn("cursor-pointer", option.disable && "cursor-default text-muted-foreground")} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = "MultipleSelector"; +export default MultipleSelector; diff --git a/src/lib/axios.ts b/src/lib/axios.ts index c5e89ba..f662ef8 100644 --- a/src/lib/axios.ts +++ b/src/lib/axios.ts @@ -36,14 +36,24 @@ export const setAuthToken = (token): void => { * Usage: fetcher({ url: "/users", method: "GET" }); */ export const fetcher = async (config: AxiosRequestConfig): Promise> => { - const { data } = await apiClient.request(config); - const responseData = data as ApiResponse; + try { + const { data } = await apiClient.request(config); + const responseData = data as ApiResponse; - if (!responseData.success) { - throw new ApiError(responseData.error, responseData); - } + if (!responseData.success) { + throw new ApiError(responseData.error, responseData); + } - return responseData; + return responseData; + } catch (error) { + console.log("🚀 ~ fetcher ~ error:", error); + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.error || error.message; + throw new ApiError(errorMessage, error.response?.data); + } else { + throw new ApiError(error.message, error); + } + } }; const apiClientForMockApi = axios.create({ diff --git a/src/lib/constants.tsx b/src/lib/constants.tsx index a684118..810918f 100644 --- a/src/lib/constants.tsx +++ b/src/lib/constants.tsx @@ -41,5 +41,13 @@ export const SIDEBAR_ITEMS: MenuItem[] = [ }, ]; -export const CREATE_EDIT_FIELDS_EXCLUDE = ["id", "reference", "updated_at", "created_at", "last_login_at", "role"]; +export const CREATE_EDIT_FIELDS_EXCLUDE = [ + "id", + "reference", + "updated_at", + "created_at", + "last_login_at", + "role", + "permission_groups", +]; export const READ_FIELDS_EXCLUDE = ["password"]; diff --git a/src/lib/hooks/use-sorting.ts b/src/lib/hooks/use-sorting.ts index 1623d75..9b16462 100644 --- a/src/lib/hooks/use-sorting.ts +++ b/src/lib/hooks/use-sorting.ts @@ -1,6 +1,6 @@ import { useState } from "react"; -export default function useSorting(initialField = "id", initialOrder = "ASC") { +export default function useSorting(initialField = "id", initialOrder = "DESC") { const [sorting, setSorting] = useState([{ id: initialField, desc: initialOrder === "DESC" }]); return { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dc0eaa6..633c5f4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -137,3 +137,14 @@ export function deepEqual(obj1: any, obj2: any): boolean { return true; } + +export const omitFields = (schema, fieldsToOmit) => { + const shape = schema.shape; + const newShape = Object.keys(shape).reduce((acc, key) => { + if (!fieldsToOmit.includes(key)) { + acc[key] = shape[key]; + } + return acc; + }, {}); + return z.object(newShape); +}; diff --git a/src/types/default-type.ts b/src/types/default-type.ts new file mode 100644 index 0000000..1906deb --- /dev/null +++ b/src/types/default-type.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +// Define the common schema fragment +export const defaultSchema = z.object({ + created_at: z.coerce.date().optional(), + updated_at: z.coerce.date().optional(), + media: z.union([z.string(), z.number()]).optional(), +}); diff --git a/src/types/employee.ts b/src/types/employee.ts index 7bb2365..99f6fe3 100644 --- a/src/types/employee.ts +++ b/src/types/employee.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import dayjs from "dayjs"; +import { defaultSchema } from "@/types/default-type"; const roleSchema = z .object({ @@ -9,20 +10,20 @@ const roleSchema = z .nullable() .optional(); -export const employeeSchema = z.object({ - id: z.preprocess((x) => "" + x, z.string()), - name: z.string(), - username: z.string(), - password: z.string().nullable(), - email: z.string().nullable(), - phone: z.string().nullable().optional(), - sex: z.string().nullable().optional(), - birth_date: z.coerce.date().optional(), - last_login_at: z.coerce.date().optional(), - created_at: z.coerce.date().optional(), - updated_at: z.coerce.date().optional(), - role: roleSchema, // nested role object -}); +export const employeeSchema = z + .object({ + id: z.preprocess((x) => "" + x, z.string()), + name: z.string(), + username: z.string(), + password: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable().optional(), + sex: z.string().nullable().optional(), + birth_date: z.coerce.date().optional(), + last_login_at: z.coerce.date().optional(), + role: roleSchema, // nested role object + }) + .extend(defaultSchema.shape); // .omit({ password: true }); export type Employee = z.infer; diff --git a/src/types/permissionGroup.ts b/src/types/permissionGroup.ts new file mode 100644 index 0000000..43be25e --- /dev/null +++ b/src/types/permissionGroup.ts @@ -0,0 +1,26 @@ +import { omitFields } from "@/lib/utils"; +import { defaultSchema } from "@/types/default-type"; +import { z } from "zod"; + +export const permissionSchema = z.object({ + id: z.number(), + name: z.string(), + display_name: z.string(), + description: z.string().nullable(), + type: z.number(), + status: z.number(), + created_at: z.string(), + updated_at: z.string(), + pivot: z.object({ + role_id: z.number(), + permission_id: z.number(), + }), +}); + +export const permissionGroupSchema = z + .object({ + he_thong: z.array(permissionSchema), + }) + .optional(); + +export type PermissionGroup = z.infer; diff --git a/src/types/request-params.ts b/src/types/request-params.ts index 5101809..4bbeaf7 100644 --- a/src/types/request-params.ts +++ b/src/types/request-params.ts @@ -14,7 +14,7 @@ export type DefaultRequestParams = PaginationParams & FilterParams; export const defaultParams: DefaultRequestParams = { order_by: "id", - sort: "ASC", + sort: "DESC", filter: {}, page: 1, limit: 10, diff --git a/src/types/role.ts b/src/types/role.ts index 4351228..1516b74 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -1,21 +1,36 @@ +import { omitFields } from "@/lib/utils"; +import { defaultSchema } from "@/types/default-type"; +import { permissionGroupSchema } from "@/types/permissionGroup"; import { z } from "zod"; -export const roleSchema = z.object({ - id: z.preprocess((x) => "" + x, z.string()), - name: z.string(), - founding_date: z.coerce.date().optional(), - leader_name: z.string().optional(), - phone: z.string().regex(/^\d+$/, { - message: "Phone number should only contain digits", - }), - phone_zalo: z - .string() - .regex(/^\d+$/, { - message: "Phone number should only contain digits", - }) - .optional(), - media: z.union([z.string(), z.number()]).optional(), -}); -// .omit({ password: true }); +export enum PermissionID { + "view-employee" = "1", + "create-employee" = "2", + "update-employee" = "3", + "delete-employee" = "4", + "export-employee" = "5", + "view-role" = "6", + "create-role" = "7", + "update-role" = "8", + "delete-role" = "9", +} + +export const roleSchema = z + .object({ + id: z.preprocess((x) => "" + x, z.string()), + name: z.string(), + display_name: z.string(), + description: z.string().optional(), + permission_ids: z.any(), + permission_groups: permissionGroupSchema, + // type: z.objec({ id: 1, value: "success" }, { id: 2, value: "pending" }, { id: 3, value: "failed" }), + }) + .extend(defaultSchema.shape); export type Role = z.infer; + +// Define the fields to omit in the create form +const createFormExcludedFields = ["created_at", "updated_at"]; + +// Create a schema for the create form +export const createRoleFormSchema = omitFields(roleSchema, createFormExcludedFields); From 38b3f8045ac231cafb839daf0cc1036a59832b16 Mon Sep 17 00:00:00 2001 From: duchuy-wins Date: Thu, 13 Jun 2024 09:48:33 +0700 Subject: [PATCH 2/3] Fix: style --- src/app/dashboard/roles/_components/columns.tsx | 16 ++++++++++------ .../dashboard/roles/_components/role-form.tsx | 1 + src/lib/constants.tsx | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/dashboard/roles/_components/columns.tsx b/src/app/dashboard/roles/_components/columns.tsx index 83a066f..c14b2ea 100644 --- a/src/app/dashboard/roles/_components/columns.tsx +++ b/src/app/dashboard/roles/_components/columns.tsx @@ -83,12 +83,16 @@ const updates: ColumnDef[] = [ const role = row.original; const permissionGroups = role.permission_groups; return ( - permissionGroups && - permissionGroups.he_thong && - permissionGroups.he_thong.length > 0 && - permissionGroups?.he_thong?.map((permission) => { - return
{permission.display_name}
; - }) +
    + {permissionGroups && + permissionGroups.he_thong && + permissionGroups.he_thong.length > 0 && + permissionGroups?.he_thong?.map((permission) => ( +
  • + {permission.display_name} +
  • + ))} +
); }, }, diff --git a/src/app/dashboard/roles/_components/role-form.tsx b/src/app/dashboard/roles/_components/role-form.tsx index 9cc4f94..90ed08e 100644 --- a/src/app/dashboard/roles/_components/role-form.tsx +++ b/src/app/dashboard/roles/_components/role-form.tsx @@ -48,6 +48,7 @@ export default function RoleForm({ type, initialData = {} }: { type: "Create" | overrides={{ permission_ids: ( Date: Sat, 15 Jun 2024 15:56:34 +0700 Subject: [PATCH 3/3] Feat: Role for employee --- .../employees/_components/employee-form.tsx | 41 +++++++++++++++---- .../_components/select-box-roles.tsx | 26 ++++++++++++ src/components/blurry-loader.tsx | 4 +- src/components/form/antd-image-upload.tsx | 12 +++--- src/components/form/modal-card.tsx | 26 ++---------- src/lib/constants.tsx | 2 +- src/lib/hooks/use-sorting.ts | 2 +- src/lib/utils.ts | 32 +++++++++++++++ src/services/role-service.ts | 3 +- src/types/employee.ts | 21 +++++----- src/types/request-params.ts | 2 +- src/types/role.ts | 4 +- 12 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 src/app/dashboard/employees/_components/select-box-roles.tsx diff --git a/src/app/dashboard/employees/_components/employee-form.tsx b/src/app/dashboard/employees/_components/employee-form.tsx index 0300b73..300683f 100644 --- a/src/app/dashboard/employees/_components/employee-form.tsx +++ b/src/app/dashboard/employees/_components/employee-form.tsx @@ -1,19 +1,23 @@ "use client"; -import { Employee, employeeSchema } from "@/types/employee"; +import { Employee, EmployeeFormSchema, employeeSchema } from "@/types/employee"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import ModalCard from "@/components/form/modal-card"; import ReactHookForm from "@/components/form/react-hook-form"; import { useCreateEmployee, useEditEmployee } from "@/services/employee-service"; import DynamicFormFields from "@/components/form/dynamic-form-fields"; -import { PasswordInput } from "@/components/ui/toggle-able-password"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { useGetRoles } from "@/services/role-service"; +import BlurryLoader from "@/components/blurry-loader"; export default function EmployeeForm({ type, initialData = {} }: { type: "Create" | "Edit"; initialData?: any }) { const { mutate: mutateCreate, isPending: isPendingCreate, isSuccess: isSuccessCreate } = useCreateEmployee(); const { mutate: mutateEdit, isPending: isPendingEdit, isSuccess: isSuccessEdit } = useEditEmployee(); + const { data: roles, isFetching: isFetchingRole } = useGetRoles(); const form = useForm({ - resolver: zodResolver(employeeSchema), + resolver: zodResolver(EmployeeFormSchema), defaultValues: initialData, }); @@ -34,12 +38,33 @@ export default function EmployeeForm({ type, initialData = {} }: { type: "Create onClose={isSuccessCreate || isSuccessEdit} > - + , - }} + name="role_id" + render={({ field }) => ( + + Role + + + + )} /> diff --git a/src/app/dashboard/employees/_components/select-box-roles.tsx b/src/app/dashboard/employees/_components/select-box-roles.tsx new file mode 100644 index 0000000..dea5b60 --- /dev/null +++ b/src/app/dashboard/employees/_components/select-box-roles.tsx @@ -0,0 +1,26 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { useGetRoles } from "@/services/role-service"; +import BlurryLoader from "@/components/blurry-loader"; + +export default function SelectBoxRoles({ form }) { + const { data: roles, isFetching: isFetchingRole } = useGetRoles(); + + return ( + + ); +} diff --git a/src/components/blurry-loader.tsx b/src/components/blurry-loader.tsx index 535dc6a..b358aac 100644 --- a/src/components/blurry-loader.tsx +++ b/src/components/blurry-loader.tsx @@ -1,12 +1,12 @@ import LoadingCircle from "@/components/icons/loading-circle"; -export default function BlurryLoader({ shouldShow }) { +export default function BlurryLoader({ shouldShow, dimensions = "w-10 h-10" }) { return ( shouldShow && ( - + ) diff --git a/src/components/form/antd-image-upload.tsx b/src/components/form/antd-image-upload.tsx index c7bc446..d58d163 100644 --- a/src/components/form/antd-image-upload.tsx +++ b/src/components/form/antd-image-upload.tsx @@ -6,7 +6,7 @@ import { useSession } from "next-auth/react"; const { Dragger } = Upload; export default function ImageUpload({ onUpload }: { onUpload: any }) { - const session = useSession(); + // const session = useSession(); const handleSubmit = () => {}; @@ -31,11 +31,11 @@ export default function ImageUpload({ onUpload }: { onUpload: any }) { name="image" multiple={true} action={`${process.env.NEXT_PUBLIC_BE_URL}/api/v1/files/uploadImage`} - headers={ - { - Authorization: `Bearer ${session.data?.accessToken}`, - } as AxiosRequestHeaders - } + // headers={ + // { + // Authorization: `Bearer ${session.data?.accessToken}`, + // } as AxiosRequestHeaders + // } onChange={handleOnChange} onDrop={handleOnDrop} // beforeUpload={() => false} // return false so that antd doesn't upload the picture right away diff --git a/src/components/form/modal-card.tsx b/src/components/form/modal-card.tsx index 8be3b9f..ae22667 100644 --- a/src/components/form/modal-card.tsx +++ b/src/components/form/modal-card.tsx @@ -2,14 +2,7 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { useModal } from "@/components/modal/provider"; import { LoadingButton } from "@/components/ui/button-with-loading"; import { UseFormReturn } from "react-hook-form"; @@ -27,14 +20,7 @@ type ModalCardProps = { isLoading?: boolean; }; -export default function ModalCard({ - form, - formId, - children, - metadata, - onClose, - isLoading, -}: ModalCardProps) { +export default function ModalCard({ form, formId, children, metadata, onClose, isLoading }: ModalCardProps) { const modal = useModal(); // Hide modal after submitted @@ -46,7 +32,7 @@ export default function ModalCard({ }, [onClose]); return ( - + {metadata.title} {metadata.description} @@ -56,11 +42,7 @@ export default function ModalCard({ - + {metadata.buttonLabel} diff --git a/src/lib/constants.tsx b/src/lib/constants.tsx index 7c55a25..8f74b41 100644 --- a/src/lib/constants.tsx +++ b/src/lib/constants.tsx @@ -50,4 +50,4 @@ export const CREATE_EDIT_FIELDS_EXCLUDE = [ "role", "permission_groups", ]; -export const READ_FIELDS_EXCLUDE = ["password", "permission_ids"]; +export const READ_FIELDS_EXCLUDE = ["password", "permission_ids", "id"]; diff --git a/src/lib/hooks/use-sorting.ts b/src/lib/hooks/use-sorting.ts index 9b16462..7bcd542 100644 --- a/src/lib/hooks/use-sorting.ts +++ b/src/lib/hooks/use-sorting.ts @@ -1,6 +1,6 @@ import { useState } from "react"; -export default function useSorting(initialField = "id", initialOrder = "DESC") { +export default function useSorting(initialField = "created_at", initialOrder = "DESC") { const [sorting, setSorting] = useState([{ id: initialField, desc: initialOrder === "DESC" }]); return { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 633c5f4..57334ac 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -148,3 +148,35 @@ export const omitFields = (schema, fieldsToOmit) => { }, {}); return z.object(newShape); }; + +export function optional(schema: TSchema) { + const entries = Object.entries(schema.shape) as [keyof TSchema["shape"], z.ZodTypeAny][]; + + const newProps = entries.reduce( + (acc, [key, value]) => { + acc[key] = value.optional(); + return acc; + }, + {} as { + [key in keyof TSchema["shape"]]: z.ZodOptional; + }, + ); + + return z.object(newProps); +} + +export function makeOptionalPropsNullable(schema: Schema) { + const entries = Object.entries(schema.shape) as [keyof Schema["shape"], z.ZodTypeAny][]; + const newProps = entries.reduce( + (acc, [key, value]) => { + acc[key] = value instanceof z.ZodOptional ? value.unwrap().nullable() : value; + return acc; + }, + {} as { + [key in keyof Schema["shape"]]: Schema["shape"][key] extends z.ZodOptional + ? z.ZodNullable + : Schema["shape"][key]; + }, + ); + return z.object(newProps); +} diff --git a/src/services/role-service.ts b/src/services/role-service.ts index 8f2554c..d7d8ec9 100644 --- a/src/services/role-service.ts +++ b/src/services/role-service.ts @@ -54,12 +54,13 @@ const editRole = async (role: Role) => { }; // Define the hook to get all roles -export const useGetRoles = (initialData: Role[], params = defaultParams) => { +export const useGetRoles = (initialData?: Role[], params = defaultParams) => { return useQuery({ queryKey: [QUERY_KEY, params], queryFn: () => fetchRoles(params), placeholderData: keepPreviousData, // The data from the last successful fetch is available while new data is being requested initialData: () => { + if (!initialData) return undefined; // first data will be fetched from server side const isInitialParams = deepEqual(params, defaultParams); return isInitialParams ? initialData : undefined; diff --git a/src/types/employee.ts b/src/types/employee.ts index 99f6fe3..6629175 100644 --- a/src/types/employee.ts +++ b/src/types/employee.ts @@ -1,29 +1,28 @@ import { z } from "zod"; -import dayjs from "dayjs"; import { defaultSchema } from "@/types/default-type"; - -const roleSchema = z - .object({ - id: z.preprocess((x) => "" + x, z.string()), - name: z.string().nullable(), - }) - .nullable() - .optional(); +import { roleSchema } from "@/types/role"; +import { omitFields } from "@/lib/utils"; export const employeeSchema = z .object({ id: z.preprocess((x) => "" + x, z.string()), name: z.string(), username: z.string(), - password: z.string().nullable(), + password: z.string().nullable().optional(), email: z.string().nullable(), phone: z.string().nullable().optional(), sex: z.string().nullable().optional(), - birth_date: z.coerce.date().optional(), last_login_at: z.coerce.date().optional(), + role_id: z.string().optional(), role: roleSchema, // nested role object }) .extend(defaultSchema.shape); // .omit({ password: true }); export type Employee = z.infer; + +// Define the fields to omit in the create form +const formExcludedFields = ["created_at", "updated_at", "role"]; + +// Create a schema for the create form +export const EmployeeFormSchema = omitFields(employeeSchema, formExcludedFields); diff --git a/src/types/request-params.ts b/src/types/request-params.ts index 4bbeaf7..804c528 100644 --- a/src/types/request-params.ts +++ b/src/types/request-params.ts @@ -13,7 +13,7 @@ type PaginationParams = { export type DefaultRequestParams = PaginationParams & FilterParams; export const defaultParams: DefaultRequestParams = { - order_by: "id", + order_by: "created_at", sort: "DESC", filter: {}, page: 1, diff --git a/src/types/role.ts b/src/types/role.ts index 1516b74..7968a29 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -20,7 +20,7 @@ export const roleSchema = z id: z.preprocess((x) => "" + x, z.string()), name: z.string(), display_name: z.string(), - description: z.string().optional(), + description: z.string().nullable().optional(), permission_ids: z.any(), permission_groups: permissionGroupSchema, // type: z.objec({ id: 1, value: "success" }, { id: 2, value: "pending" }, { id: 3, value: "failed" }), @@ -30,7 +30,7 @@ export const roleSchema = z export type Role = z.infer; // Define the fields to omit in the create form -const createFormExcludedFields = ["created_at", "updated_at"]; +const createFormExcludedFields = ["created_at", "updated_at", "role"]; // Create a schema for the create form export const createRoleFormSchema = omitFields(roleSchema, createFormExcludedFields);