From 2b049ec652a4fae382c40d878387583301438044 Mon Sep 17 00:00:00 2001 From: Vladyslav Holik Date: Mon, 18 Sep 2023 21:03:17 +0300 Subject: [PATCH] th-249: Redesign UI table (#262) * th-249: * update table styles * th-249: * update pagination and dropdown styles * th-249: + edit&delete icon and ability to sort columns * th-249: * changed props name * th-249: + add manualSorting parameter * th-249: + add helper for checking icon cell * th-249: * refactored table and helper * th-249: * splits table.tsx into different files * th-249: - removes unused props * th-249: * changed types and types' names --- frontend/src/assets/css/fonts.scss | 6 +- frontend/src/assets/css/vars.scss | 2 + .../src/libs/components/button/button.tsx | 2 +- .../src/libs/components/dropdown/dropdown.tsx | 57 +++++++--- .../icon/maps/icon-name-to-svg.map.ts | 6 + .../libs/components/pagination/pagination.tsx | 38 +++++-- .../components/pagination/styles.module.scss | 62 ++++++++-- .../table/libs/components/components.ts | 2 + .../table/libs/components/tbody.tsx | 68 +++++++++++ .../table/libs/components/thead.tsx | 56 +++++++++ .../libs/helpers/add-icons-to-data.helper.ts | 33 ++++++ .../helpers/check-is-action-cell.helper.ts | 30 +++++ .../helpers/get-cell-click-handler.helper.ts | 34 ++++++ .../components/table/libs/helpers/helpers.ts | 3 + .../libs/components/table/styles.module.scss | 76 ++++++++----- frontend/src/libs/components/table/table.tsx | 106 +++++++----------- frontend/src/libs/enums/icon-name.enum.ts | 5 +- 17 files changed, 449 insertions(+), 137 deletions(-) create mode 100644 frontend/src/libs/components/table/libs/components/components.ts create mode 100644 frontend/src/libs/components/table/libs/components/tbody.tsx create mode 100644 frontend/src/libs/components/table/libs/components/thead.tsx create mode 100644 frontend/src/libs/components/table/libs/helpers/add-icons-to-data.helper.ts create mode 100644 frontend/src/libs/components/table/libs/helpers/check-is-action-cell.helper.ts create mode 100644 frontend/src/libs/components/table/libs/helpers/get-cell-click-handler.helper.ts create mode 100644 frontend/src/libs/components/table/libs/helpers/helpers.ts diff --git a/frontend/src/assets/css/fonts.scss b/frontend/src/assets/css/fonts.scss index 6a17dee5b..8b5d41b26 100644 --- a/frontend/src/assets/css/fonts.scss +++ b/frontend/src/assets/css/fonts.scss @@ -1,13 +1,13 @@ @font-face { font-weight: 400; font-family: "Plus Jakarta Sans"; - src: url("fonts/plusjakartasans-regular.woff2"); + src: url("/fonts/plusjakartasans-regular.woff2"); } @font-face { font-weight: 600; font-family: "Plus Jakarta Sans"; - src: url("fonts/plusjakartasans-semibold.woff2"); + src: url("/fonts/plusjakartasans-semibold.woff2"); } @font-face { @@ -19,5 +19,5 @@ @font-face { font-weight: 800; font-family: "Plus Jakarta Sans"; - src: url("fonts/plusjakartasans-extrabold.woff2"); + src: url("/fonts/plusjakartasans-extrabold.woff2"); } diff --git a/frontend/src/assets/css/vars.scss b/frontend/src/assets/css/vars.scss index 7f9cac0f1..3f23b53c0 100644 --- a/frontend/src/assets/css/vars.scss +++ b/frontend/src/assets/css/vars.scss @@ -5,9 +5,11 @@ $white: #ffffff; $red-light: #ff437b; $red: #ee2a64; $red-dark: #d51a52; +$white-blue: #adc2ff; $blue-light: #527efe; $blue: #507ceb; $blue-dark: #3563e9; +$blue-extra-dark: #000b6a; $black-blue: #1a202c; $grey-extra-light: #ebf3ff; $grey-border: #e7eef6; diff --git a/frontend/src/libs/components/button/button.tsx b/frontend/src/libs/components/button/button.tsx index 42dd1f7c1..8f4ffc3dc 100644 --- a/frontend/src/libs/components/button/button.tsx +++ b/frontend/src/libs/components/button/button.tsx @@ -7,7 +7,7 @@ import styles from './styles.module.scss'; type Properties = { className?: Parameters[0]; - label: string; + label: string | number; type?: 'button' | 'submit'; size?: 'sm' | 'md'; variant?: 'contained' | 'outlined' | 'text'; diff --git a/frontend/src/libs/components/dropdown/dropdown.tsx b/frontend/src/libs/components/dropdown/dropdown.tsx index da6e396d7..26f9be978 100644 --- a/frontend/src/libs/components/dropdown/dropdown.tsx +++ b/frontend/src/libs/components/dropdown/dropdown.tsx @@ -28,24 +28,44 @@ type Properties = { placeholder?: string; field?: ControllerRenderProps>; className?: string; + isCustomValueContainer?: boolean; }; -const getClassNames = ( - isMenuOpen: boolean, -): ClassNamesConfig> => ({ - container: () => styles.container, - control: () => styles.control, - option: () => styles.option, - menu: () => styles.singleValue, - placeholder: () => styles.placeholder, - singleValue: () => styles.singleValue, - valueContainer: () => styles.valueContainer, - dropdownIndicator: () => - isMenuOpen - ? getValidClassNames(styles.dropdownIndicator, styles.upside) - : styles.dropdownIndicator, - indicatorSeparator: () => styles.indicatorSeparator, -}); +type GetClassNamesArguments = { + isMenuOpen: boolean; + isCustomValueContainer: boolean; +}; + +const getClassNames = ({ + isMenuOpen, + isCustomValueContainer, +}: GetClassNamesArguments): ClassNamesConfig< + SelectOption, + false, + GroupBase +> => { + const commonStylesConfig: ClassNamesConfig< + SelectOption, + false, + GroupBase + > = { + container: () => styles.container, + control: () => styles.control, + option: () => styles.option, + menu: () => styles.singleValue, + placeholder: () => styles.placeholder, + singleValue: () => styles.singleValue, + dropdownIndicator: () => + isMenuOpen + ? getValidClassNames(styles.dropdownIndicator, styles.upside) + : styles.dropdownIndicator, + indicatorSeparator: () => styles.indicatorSeparator, + }; + + return isCustomValueContainer + ? commonStylesConfig + : { ...commonStylesConfig, valueContainer: () => styles.valueContainer }; +}; const Dropdown = ({ options, @@ -56,6 +76,7 @@ const Dropdown = ({ onChange, className, placeholder, + isCustomValueContainer = false, }: Properties): JSX.Element => { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -68,8 +89,8 @@ const Dropdown = ({ }, []); const classNamesConfig = useMemo( - () => getClassNames(isMenuOpen), - [isMenuOpen], + () => getClassNames({ isMenuOpen, isCustomValueContainer }), + [isMenuOpen, isCustomValueContainer], ); const findOptionByValue = ( diff --git a/frontend/src/libs/components/icon/maps/icon-name-to-svg.map.ts b/frontend/src/libs/components/icon/maps/icon-name-to-svg.map.ts index 222b7cb41..1d4e5fe31 100644 --- a/frontend/src/libs/components/icon/maps/icon-name-to-svg.map.ts +++ b/frontend/src/libs/components/icon/maps/icon-name-to-svg.map.ts @@ -6,6 +6,7 @@ import { faCheck, faChevronDown, faChevronLeft, + faChevronUp, faClockRotateLeft, faCloudUploadAlt, faEye, @@ -14,9 +15,11 @@ import { faListUl, faLocationDot, faMap, + faPen, faPlus, faRightFromBracket, faStar, + faTrashCan, faTruckPickup, faUserPen, faUsers, @@ -32,6 +35,8 @@ const iconNameToSvg: Record, IconDefinition> = { [IconName.CARET_DOWN]: faCaretDown, [IconName.CHEVRON_DOWN]: faChevronDown, [IconName.CHEVRON_LEFT]: faChevronLeft, + [IconName.CHEVRON_UP]: faChevronUp, + [IconName.EDIT]: faPen, [IconName.GEAR]: faGear, [IconName.STAR]: faStar, [IconName.LOCATION_DOT]: faLocationDot, @@ -45,6 +50,7 @@ const iconNameToSvg: Record, IconDefinition> = { [IconName.CLOCK_ROTATE_LEFT]: faClockRotateLeft, [IconName.USER_PEN]: faUserPen, [IconName.RIGHT_FROM_BRACKET]: faRightFromBracket, + [IconName.TRASH]: faTrashCan, [IconName.TRUCK]: faTruckPickup, [IconName.USERS]: faUsers, [IconName.XMARK]: faXmark, diff --git a/frontend/src/libs/components/pagination/pagination.tsx b/frontend/src/libs/components/pagination/pagination.tsx index 43ed05db8..b3c66a3f8 100644 --- a/frontend/src/libs/components/pagination/pagination.tsx +++ b/frontend/src/libs/components/pagination/pagination.tsx @@ -1,6 +1,5 @@ import { type SingleValue } from 'react-select'; -import { getValidClassNames } from '~/libs/helpers/helpers.js'; import { useCallback } from '~/libs/hooks/hooks.js'; import { type SelectOption } from '~/libs/types/select-option.type.js'; @@ -81,15 +80,13 @@ const Pagination: React.FC = ({ const buttons: JSX.Element[] = []; for (let index = startIndex; index <= endIndex; index++) { - const buttonClass = getValidClassNames(styles.btn, { - [styles.active]: index === pageIndex, - }); buttons.push( , ); } @@ -122,17 +119,41 @@ const Pagination: React.FC = ({
+
...
+ + )} {showButtons()} - {isHiddenLastPage ?
...
: null} + {isHiddenLastPage && ( + <> +
...
+ + + )}
diff --git a/frontend/src/libs/components/pagination/styles.module.scss b/frontend/src/libs/components/pagination/styles.module.scss index 7be980ba1..72d5c652d 100644 --- a/frontend/src/libs/components/pagination/styles.module.scss +++ b/frontend/src/libs/components/pagination/styles.module.scss @@ -4,7 +4,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 20px; + gap: 25px; margin-top: 20px; } @@ -14,27 +14,62 @@ } .dots { + display: flex; + align-items: center; margin: 0 5px; - color: $red-dark; + color: $blue-extra-dark; font-size: 22px; + cursor: default; + user-select: none; +} + +.btn, +.text-btn { + color: $blue-extra-dark; } .btn { min-width: 22px; margin: 1px; - padding: 5px; - color: $red-dark; - background-color: $white; - border: 1px solid $red-dark; + padding: 8px 12px; + border-width: 1px; + border-color: $blue-extra-dark; + + &:hover { + color: $blue-extra-dark; + border-color: $blue-extra-dark; + } + + &:focus { + color: $blue-extra-dark; + border-color: $blue-extra-dark; + } } -.active { - color: white; - background-color: $red; +.text-btn { + margin-inline: 12px; + text-transform: capitalize; + + &:hover, + &:focus { + color: $blue-extra-dark; + } + + &:disabled { + color: $grey; + + &:hover { + color: $grey; + } + } } .size { - color: $red-dark; + display: flex; + align-items: center; + gap: 12px; + color: $white-blue; + font-weight: 700; } .select { @@ -44,3 +79,10 @@ border-radius: 4px; outline: none; } + +.dropdown { + padding-inline: 5px; + text-align: center; + border: 1px solid $grey-light; + border-radius: 8px; +} diff --git a/frontend/src/libs/components/table/libs/components/components.ts b/frontend/src/libs/components/table/libs/components/components.ts new file mode 100644 index 000000000..cf4ea9fc7 --- /dev/null +++ b/frontend/src/libs/components/table/libs/components/components.ts @@ -0,0 +1,2 @@ +export { Tbody } from './tbody.js'; +export { Thead } from './thead.js'; diff --git a/frontend/src/libs/components/table/libs/components/tbody.tsx b/frontend/src/libs/components/table/libs/components/tbody.tsx new file mode 100644 index 000000000..aacfddfad --- /dev/null +++ b/frontend/src/libs/components/table/libs/components/tbody.tsx @@ -0,0 +1,68 @@ +import { type Table } from '@tanstack/react-table'; +import { type RowData } from '@tanstack/table-core/src/types'; + +import { Icon } from '~/libs/components/components.js'; +import { IconName } from '~/libs/enums/enums.js'; +import { getValidClassNames } from '~/libs/helpers/helpers.js'; +import { flexRender } from '~/libs/hooks/hooks.js'; + +import styles from '../../styles.module.scss'; +import { checkIsActionCell, getCellClickHandler } from '../helpers/helpers.js'; + +type Properties = { + table: Table; + isTableEditable?: boolean; + onEditClick?: (rowId: string) => void; + onDeleteClick?: (rowId: string) => void; +}; + +const Tbody = ({ + table, + isTableEditable = false, + ...properties +}: Properties): JSX.Element => ( + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell, index, array) => { + const { isEditCell, isDeleteCell } = checkIsActionCell({ + isTableEditable, + index, + totalCellsInRow: array.length, + }); + const shouldAddIconToCell = isEditCell || isDeleteCell; + const iconToDisplay = isEditCell ? IconName.EDIT : IconName.TRASH; + + return ( + + {!shouldAddIconToCell && + flexRender(cell.column.columnDef.cell, cell.getContext())} + {shouldAddIconToCell && ( + + )} + + ); + })} + + ))} + +); + +export { Tbody }; diff --git a/frontend/src/libs/components/table/libs/components/thead.tsx b/frontend/src/libs/components/table/libs/components/thead.tsx new file mode 100644 index 000000000..ceb4ca3f1 --- /dev/null +++ b/frontend/src/libs/components/table/libs/components/thead.tsx @@ -0,0 +1,56 @@ +import { type Table } from '@tanstack/react-table'; +import { type RowData } from '@tanstack/table-core/src/types'; + +import { Icon } from '~/libs/components/components.js'; +import { IconName } from '~/libs/enums/enums.js'; +import { getValidClassNames } from '~/libs/helpers/helpers.js'; +import { flexRender } from '~/libs/hooks/hooks.js'; + +import styles from '../../styles.module.scss'; + +type Properties = { + table: Table; +}; + +const Thead = ({ table }: Properties): JSX.Element => ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+ + ))} + + ))} + +); + +export { Thead }; diff --git a/frontend/src/libs/components/table/libs/helpers/add-icons-to-data.helper.ts b/frontend/src/libs/components/table/libs/helpers/add-icons-to-data.helper.ts new file mode 100644 index 000000000..0ace25f77 --- /dev/null +++ b/frontend/src/libs/components/table/libs/helpers/add-icons-to-data.helper.ts @@ -0,0 +1,33 @@ +import { type ColumnDef } from '~/libs/types/types.js'; + +type AddIconsToData = { + data: T[]; + columns: ColumnDef[]; +}; + +const addIconsToData = ( + data: T[], + columns: ColumnDef[], +): AddIconsToData => { + const columnsWithIcons = [ + ...columns, + { + header: '', + accessorKey: 'iconEdit', + size: 50, + enableResizing: false, + enableSorting: false, + }, + { + header: '', + accessorKey: 'iconDelete', + size: 50, + enableResizing: false, + enableSorting: false, + }, + ]; + + return { data, columns: columnsWithIcons }; +}; + +export { addIconsToData }; diff --git a/frontend/src/libs/components/table/libs/helpers/check-is-action-cell.helper.ts b/frontend/src/libs/components/table/libs/helpers/check-is-action-cell.helper.ts new file mode 100644 index 000000000..35ede4405 --- /dev/null +++ b/frontend/src/libs/components/table/libs/helpers/check-is-action-cell.helper.ts @@ -0,0 +1,30 @@ +const EDIT_ICON_POSITION_FROM_RIGHT = 2; +const DELETE_ICON_POSITION_FROM_RIGHT = 1; + +type Arguments = { + isTableEditable: boolean; + index: number; + totalCellsInRow: number; +}; + +type CheckIsIconCell = { + isEditCell: boolean; + isDeleteCell: boolean; +}; + +const checkIsActionCell = ({ + isTableEditable, + index, + totalCellsInRow, +}: Arguments): CheckIsIconCell => { + return { + isEditCell: + isTableEditable && + index === totalCellsInRow - EDIT_ICON_POSITION_FROM_RIGHT, + isDeleteCell: + isTableEditable && + index === totalCellsInRow - DELETE_ICON_POSITION_FROM_RIGHT, + }; +}; + +export { checkIsActionCell }; diff --git a/frontend/src/libs/components/table/libs/helpers/get-cell-click-handler.helper.ts b/frontend/src/libs/components/table/libs/helpers/get-cell-click-handler.helper.ts new file mode 100644 index 000000000..9d0ee04f8 --- /dev/null +++ b/frontend/src/libs/components/table/libs/helpers/get-cell-click-handler.helper.ts @@ -0,0 +1,34 @@ +import { checkIsActionCell } from './check-is-action-cell.helper.js'; + +type Arguments = { + index: number; + totalCellsInRow: number; + isTableEditable: boolean; + onEditClick?: (rowId: string) => void; + onDeleteClick?: (rowId: string) => void; +}; + +const getCellClickHandler = ({ + index, + totalCellsInRow, + isTableEditable = false, + onEditClick, + onDeleteClick, +}: Arguments): typeof onEditClick | typeof onDeleteClick => { + if (!isTableEditable) { + return; + } + const { isEditCell } = checkIsActionCell({ + isTableEditable, + index, + totalCellsInRow, + }); + + if (isEditCell) { + return onEditClick; + } + + return onDeleteClick; +}; + +export { getCellClickHandler }; diff --git a/frontend/src/libs/components/table/libs/helpers/helpers.ts b/frontend/src/libs/components/table/libs/helpers/helpers.ts new file mode 100644 index 000000000..ad6e6a039 --- /dev/null +++ b/frontend/src/libs/components/table/libs/helpers/helpers.ts @@ -0,0 +1,3 @@ +export { addIconsToData } from './add-icons-to-data.helper.js'; +export { checkIsActionCell } from './check-is-action-cell.helper.js'; +export { getCellClickHandler } from './get-cell-click-handler.helper.js'; diff --git a/frontend/src/libs/components/table/styles.module.scss b/frontend/src/libs/components/table/styles.module.scss index f13208d3d..fb86c6593 100644 --- a/frontend/src/libs/components/table/styles.module.scss +++ b/frontend/src/libs/components/table/styles.module.scss @@ -1,4 +1,4 @@ -@import "src/assets/css/styles.scss"; +@import "src/assets/css/vars.scss"; .container { display: flex; @@ -8,25 +8,33 @@ gap: 20px; } -.table { - background-color: rgb(248 246 246); +.wrapper { + overflow: hidden; + border: 1px solid $white-blue; border-radius: 8px; +} + +.table { border-collapse: collapse; } .thead { - color: white; - background-color: $header-background; + color: $black; + text-align: center; + background-color: $grey-light; } .th, .td { - padding: 10px 15px; - border: 1px solid white; + padding: 18px 16px; } .th { position: relative; + + &:first-child { + text-align: left; + } } .resizer { @@ -47,40 +55,52 @@ opacity: 1; } -.th:first-child { - border: none; - border-top-left-radius: 8px; +.td { + font-weight: $font-weight-regular; + font-size: 14px; + text-align: center; + + &:first-child { + font-size: 16px; + } } -.th:last-child { - border: none; - border-top-right-radius: 8px; +.text-left { + text-align: left; +} - .resizer { - border-top-right-radius: 8px; - } +.tr:not(:last-child) { + box-shadow: 0 -1px 0 0 $grey-light inset; } -.td { - text-align: center; +.icon-edit { + color: $grey; + cursor: pointer; } -.tr:nth-child(even) { - background-color: #b9d1f1fa; +.icon-delete { + color: $grey-light; } -.tr:last-child { - .td:first-child { - border: none; - border-bottom-left-radius: 8px; - } +.icon-edit, +.icon-delete { + cursor: pointer; - .td:last-child { - border: none; - border-bottom-right-radius: 8px; + &:hover { + background-color: $grey-extra-light; } } +.sorted { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sorting { + cursor: pointer; +} + @media (hover: hover) { .resizer { opacity: 0; diff --git a/frontend/src/libs/components/table/table.tsx b/frontend/src/libs/components/table/table.tsx index ab823f16b..f9d159e7f 100644 --- a/frontend/src/libs/components/table/table.tsx +++ b/frontend/src/libs/components/table/table.tsx @@ -1,25 +1,33 @@ -import { getValidClassNames } from '~/libs/helpers/helpers.js'; +import { type OnChangeFn, type SortingState } from '@tanstack/react-table'; + import { - flexRender, getCoreRowModel, getPaginationRowModel, useCallback, + useMemo, useReactTable, } from '~/libs/hooks/hooks.js'; import { type ColumnDef } from '~/libs/types/types.js'; import { Pagination } from '../pagination/pagination.jsx'; +import { Tbody, Thead } from './libs/components/components.js'; import { DEFAULT_COLUMN } from './libs/constant.js'; +import { addIconsToData } from './libs/helpers/helpers.js'; import styles from './styles.module.scss'; type Properties = { data: T[]; + isTableEditable?: boolean; columns: ColumnDef[]; pageSize: number; totalRow: number; pageIndex: number; + sorting?: SortingState; + setSorting?: OnChangeFn; changePageIndex: React.Dispatch>; changePageSize: React.Dispatch>; + onEditClick?: (rowId: string) => void; + onDeleteClick?: (rowId: string) => void; }; const Table = ({ @@ -28,17 +36,28 @@ const Table = ({ totalRow, pageSize, pageIndex, + isTableEditable = false, + sorting, + setSorting, changePageIndex, changePageSize, + ...properties }: Properties): JSX.Element => { const pagesRange = Math.ceil(totalRow / pageSize); + const dataAndColumns = useMemo(() => { + return isTableEditable ? addIconsToData(data, columns) : { data, columns }; + }, [columns, data, isTableEditable]); const table = useReactTable({ - data, - columns, + ...dataAndColumns, columnResizeMode: 'onChange', defaultColumn: DEFAULT_COLUMN, + state: { + sorting, + }, + manualSorting: true, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, initialState: { pagination: { pageSize, @@ -55,72 +74,23 @@ const Table = ({ [changePageSize, table, changePageIndex], ); - const createThead = (): JSX.Element => ( - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- - ))} - - ))} - - ); - - const createTbody = (): JSX.Element => ( - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - - ); - return (
- - {createThead()} - {createTbody()} -
+
+ + + +
+