diff --git a/package-lock.json b/package-lock.json index 174d8aeb..cbaf67ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/node": "18.15.1", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", + "@uidotdev/usehooks": "^2.4.1", "class-validator": "^0.14.0", "classnames": "^2.3.2", "crypto-random-string": "^5.0.0", @@ -23,6 +24,7 @@ "eslint": "8.36.0", "eslint-config-next": "13.2.4", "form-data": "^4.0.0", + "heroicons": "^2.1.5", "jwt-decode": "^3.1.2", "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.151", "next": "^14.2.3", @@ -1153,6 +1155,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uidotdev/usehooks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -3174,6 +3188,11 @@ "node": ">= 0.4" } }, + "node_modules/heroicons": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/heroicons/-/heroicons-2.1.5.tgz", + "integrity": "sha512-XLq3m45bJphmWdR6im52alaYajp0/fluJa2+7xh3x7CgItumbLsjhKYe+mCf0lErXLy7ZyiEgKIty2gFNxhoyA==" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index dac22059..76f7e6ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/node": "18.15.1", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", + "@uidotdev/usehooks": "^2.4.1", "class-validator": "^0.14.0", "classnames": "^2.3.2", "crypto-random-string": "^5.0.0", @@ -25,6 +26,7 @@ "eslint": "8.36.0", "eslint-config-next": "13.2.4", "form-data": "^4.0.0", + "heroicons": "^2.1.5", "jwt-decode": "^3.1.2", "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.151", "next": "^14.2.3", diff --git a/src/front/Components/DesignSystem/Table/MuiTable/classes.module.scss b/src/front/Components/DesignSystem/Table/MuiTable/classes.module.scss new file mode 100644 index 00000000..cdc48752 --- /dev/null +++ b/src/front/Components/DesignSystem/Table/MuiTable/classes.module.scss @@ -0,0 +1,40 @@ +.root { + .head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + overflow: hidden; + + .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + svg { + cursor: pointer; + min-width: 21px; + fill: var(--color-neutral-600); + } + } + + .cell { + overflow: hidden; + word-wrap: break-word; + + .content { + max-width: 270px; + width: 100%; + word-break: break-word; + + > :first-child { + width: 100%; + } + } + + &:hover { + background-color: var(--background-elevation-1); + } + } +} diff --git a/src/front/Components/DesignSystem/Table/MuiTable/index.tsx b/src/front/Components/DesignSystem/Table/MuiTable/index.tsx new file mode 100644 index 00000000..c8d6295f --- /dev/null +++ b/src/front/Components/DesignSystem/Table/MuiTable/index.tsx @@ -0,0 +1,101 @@ +import InfiniteScroll from "@Front/Components/Elements/InfiniteScroll"; +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; + +import Typography, { ETypo, ETypoColor } from "../../Typography"; +import classes from "./classes.module.scss"; + +export type IRowProps = { key: string } & Record; + +type IRow = { + key?: string; + content: Record; +}; + +type IProps = { + header: readonly IHead[]; + rows: IRowProps[]; + onNext?: ((release: () => void, reset?: () => void) => Promise | void) | null; +}; + +export type IHead = { + key: string; + title?: string; +}; + +type CellContent = { + key: string; + value: React.ReactNode; +}; + +export default function MuiTable(props: IProps) { + const rows: IRow[] = props.rows.map((rowProps) => { + const row: IRow = { + key: rowProps.key, + content: {}, + }; + props.header.forEach((column) => { + const cellContent: CellContent = { + key: column.key + rowProps.key, + value: rowProps[column.key], + }; + row.content[column.key] = cellContent; + }); + return row; + }); + + return ( + + + + + + {props.header.map((column) => ( + + {column.title && ( + + + {column.title} + + + + )} + + ))} + + + + {rows.map((row) => { + return ( + + {Object.values(row.content).map((cell) => ( + + + {cell.value} + + + ))} + + ); + })} + +
+
+
+ ); +} diff --git a/src/front/Components/DesignSystem/Table/SearchBar/classes.module.scss b/src/front/Components/DesignSystem/Table/SearchBar/classes.module.scss new file mode 100644 index 00000000..384a9a0a --- /dev/null +++ b/src/front/Components/DesignSystem/Table/SearchBar/classes.module.scss @@ -0,0 +1,40 @@ +.root { + display: flex; + position: relative; + align-items: center; + justify-content: space-between; + width: 100%; + border: 1px solid var(--Wild-Sand-100, #efefef); + background-color: var(--Wild-Sand-50, #f6f6f6); + box-sizing: border-box; + + .input-element { + padding: 10.5px 16px; + flex: 1; + border: 0; + background-color: transparent; + + &:focus, + input:focus { + outline: none; + } + + &::placeholder { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + } + + .icon { + position: absolute; + right: 10px; + width: 24px; + height: 24px; + pointer-events: none; + path { + stroke: var(--Wild-Sand-600, #bfbfbf); + } + } +} diff --git a/src/front/Components/DesignSystem/Table/SearchBar/index.tsx b/src/front/Components/DesignSystem/Table/SearchBar/index.tsx new file mode 100644 index 00000000..c407caf6 --- /dev/null +++ b/src/front/Components/DesignSystem/Table/SearchBar/index.tsx @@ -0,0 +1,33 @@ +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import classes from "./classes.module.scss"; + +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { useDebounce } from "@uidotdev/usehooks"; + +type IProps = { + onChange?: (search: string) => void; + placeholder: string; +}; + +export default function SearchBar(props: IProps) { + const { placeholder, onChange } = props; + + const [search, setSearch] = useState(null); + const debouncedSearch = useDebounce(search, 200); + + const onSearch = useCallback((e: ChangeEvent) => { + setSearch(e.currentTarget.value); + }, []); + + useEffect(() => { + if (debouncedSearch === null) return; + onChange?.(debouncedSearch); + }, [debouncedSearch, onChange]); + + return ( +
+ + +
+ ); +} diff --git a/src/front/Components/DesignSystem/Table/classes.module.scss b/src/front/Components/DesignSystem/Table/classes.module.scss new file mode 100644 index 00000000..354de795 --- /dev/null +++ b/src/front/Components/DesignSystem/Table/classes.module.scss @@ -0,0 +1,13 @@ +.root { + .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + + .input-container { + width: 300px; + cursor: text; + } + } +} diff --git a/src/front/Components/DesignSystem/Table/index.tsx b/src/front/Components/DesignSystem/Table/index.tsx new file mode 100644 index 00000000..1c4f2d15 --- /dev/null +++ b/src/front/Components/DesignSystem/Table/index.tsx @@ -0,0 +1,45 @@ +import classNames from "classnames"; +import MuiTable, { IRowProps, IHead } from "./MuiTable"; +import SearchBarTable from "./SearchBar"; +import classes from "./classes.module.scss"; +import { useCallback, useRef } from "react"; + +type IProps = { + header: readonly IHead[]; + rows: IRowProps[]; + count?: number; + className?: string; + onNext?: ((release: () => void, reset?: () => void) => Promise | void) | null; + searchBar?: { + placeholder?: string; + onSearch?: (search: string) => void; + }; +}; + +export default function Table(props: IProps) { + const { className, header, rows, searchBar } = props; + const keyId = useRef(0); + + const onSearch = useCallback( + (search: string) => { + keyId.current++; + searchBar?.onSearch?.(search); + }, + [searchBar], + ); + + return ( +
+ {searchBar && ( +
+
{props.count ?? rows.length} resultats
+
+ +
+
+ )} + + +
+ ); +} diff --git a/src/front/Components/DesignSystem/Tag/classes.module.scss b/src/front/Components/DesignSystem/Tag/classes.module.scss index 69c4b841..084dfaac 100644 --- a/src/front/Components/DesignSystem/Tag/classes.module.scss +++ b/src/front/Components/DesignSystem/Tag/classes.module.scss @@ -6,6 +6,7 @@ display: flex; align-items: center; + justify-content: center; &.info { background-color: var(--color-info-50); diff --git a/src/front/Components/DesignSystem/Typography/index-old.tsx b/src/front/Components/DesignSystem/Typography/index-old.tsx deleted file mode 100644 index b535f96d..00000000 --- a/src/front/Components/DesignSystem/Typography/index-old.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import classes from "./old-classes.module.scss"; -import classNames from "classnames"; - -type IProps = { - typo: ITypo; - children: React.ReactNode; - color?: ITypoColor; - className?: string; - title?: string; -}; -type IState = {}; - -export enum ITypo { - H1 = "H1-60", - H1Bis = "H1-bis-40", - H2 = "H2-30", - H3 = "H3-24", - - P_SB_18 = "Paragraphe-semibold-18", - P_18 = "Paragraphe-simple-18", - P_MAJ_18 = "Paragraphe-MAJ-18", - NAV_HEADER_18 = "Nav-header-off-18", - P_ERR_18 = "Paragraphe-18-error", - - P_SB_16 = "Paragraphe-semibold-16", - P_16 = "Paragraphe-simple-16", - NAV_INPUT_16 = "Nav-input-off-16", - P_ERR_16 = "Paragraphe-16-error", - - CAPTION_14 = "Caption_14", - CAPTION_14_SB = "Caption_14-semibold", -} - -export enum ITypoColor { - COLOR_ERROR_800 = "color-error-800", - COLOR_NEUTRAL_500 = "color-neutral-500", - COLOR_GENERIC_BLACK = "color-generic-black", - COLOR_PRIMARY_500 = "color-primary-500", - COLOR_SECONDARY_500 = "color-secondary-500", - COLOR_SUCCESS_600 = "color-success-600", - COLOR_WARNING_500 = "color-warning-500", - COLOR_ERROR_600 = "color-error-600", - COLOR_GENERIC_WHITE = "color-generic-white", -} - -export default class Typography extends React.Component { - public override render(): JSX.Element { - return ( -
- {this.props.children} -
- ); - } -} diff --git a/src/front/Components/DesignSystem/Typography/old-classes.module.scss b/src/front/Components/DesignSystem/Typography/old-classes.module.scss deleted file mode 100644 index da61f6ce..00000000 --- a/src/front/Components/DesignSystem/Typography/old-classes.module.scss +++ /dev/null @@ -1,166 +0,0 @@ -@import "@Themes/constants.scss"; -@import "@Themes/modes.scss"; - -.root { - color: var(--color-generic-black); - vertical-align: center; - font-family: "Inter", sans-serif; - &.H1-60 { - font-style: normal; - font-weight: 500; - font-size: 56px; - line-height: 67.2px; - - @media (max-width: $screen-m) { - font-size: 48px; - line-height: 56.7px; - } - } - - &.H1-bis-40 { - font-style: normal; - font-weight: 500; - font-size: 40px; - line-height: 48px; - } - - &.H2-30 { - font-style: normal; - font-weight: 500; - font-size: 30px; - line-height: 36px; - } - - &.H3-24 { - font-style: normal; - font-weight: 600; - font-size: 24px; - line-height: 29px; - } - - &.Paragraphe-semibold-18 { - font-style: normal; - font-weight: 600; - font-size: 18px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.Paragraphe-simple-18 { - font-style: normal; - font-weight: 400; - font-size: 18px; - line-height: 22px; - } - - &.Paragraphe-MAJ-18 { - font-style: normal; - font-weight: 400; - font-size: 18px; - line-height: 22px; - text-transform: uppercase; - } - - &.Nav-header-off-18 { - font-style: normal; - font-weight: 400; - font-size: 18px; - line-height: 22px; - letter-spacing: 0.5px; - color: var(--color-neutral-500); - } - - &.Paragraphe-18-error { - font-style: normal; - font-weight: 400; - font-size: 18px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.Paragraphe-semibold-16 { - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.Nav-input-off-16 { - font-style: normal; - font-weight: 400; - font-size: 16px; - line-height: 22px; - letter-spacing: 0.5px; - color: var(--color-neutral-500); - } - - &.Paragraphe-simple-16 { - font-style: normal; - font-weight: 400; - font-size: 16px; - line-height: 22px; - letter-spacing: 0.005em; - } - - &.Paragraphe-16-error { - color: var(--color-error-800; - font-style: normal; - font-weight: 400; - font-size: 16px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.Caption_14 { - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.Caption_14-semibold { - font-style: normal; - font-weight: 600; - font-size: 14px; - line-height: 22px; - letter-spacing: 0.5px; - } - - &.color-error-800 { - color: var(--color-error-800; - } - - &.color-neutral-500 { - color: var(--color-neutral-500); - } - - &.color-generic-black { - color: var(--color-generic-black); - } - - &.color-primary-500 { - color: var(--color-primary-500); - } - - &.color-secondary-500 { - color: var(--color-secondary-500); - } - - &.color-success-600 { - color: var(--color-success-600); - } - - &.color-error-600 { - color: var(--color-error-600); - } - - &.color-warning-500 { - color: var(--color-warning-500); - } - - &.white { - color: var(--color-generic-white); - } -} diff --git a/src/front/Components/Elements/InfiniteScroll/index.tsx b/src/front/Components/Elements/InfiniteScroll/index.tsx new file mode 100644 index 00000000..15ee44a4 --- /dev/null +++ b/src/front/Components/Elements/InfiniteScroll/index.tsx @@ -0,0 +1,105 @@ +import React from "react"; + +/** + * @Documentation + * This component is used to create an infinite scroll effect. + * + * Usage: + * + * No pagination: front-end/src/components/pages/Products/index.tsx + + const onSearch = useCallback((searchParam: string) => productService.search(searchParam).then((products) => setRows(buildRows(products))), []); + + useEffect(() => { + productService.getLastProductSheets().then((products) => setRows(buildRows(products))); + }, []); + * + * + * + * With pagination: front-end/src/components/pages/Clients/index.tsx + * + const fetchClients = useCallback( + () => + clientService.get(pagination.current, search.current).then((clients) => { + if (clients.length === 0) return []; + setRows((_rows) => [..._rows, ...buildRows(clients)]); + pagination.current.skip += pagination.current.take; + return clients; + }), + [], + ); + + const onNext = useCallback( + (release: () => void) => { + fetchClients().then((clients) => { + if (!clients.length) return console.warn("No more value to load"); + release(); + }); + }, + [fetchClients], + ); + + const onSearch = useCallback((searchParam: string) => { + pagination.current.skip = 0; + search.current = (searchParam && searchParam.trim()) || null; + setRows([]); + }, []); + * + */ +export type IPagination = { + take: number; + skip: number; +}; + +type IProps = { + offset?: number; + orientation?: "vertical" | "horizontal"; + /** + * @description + * If `onNext` is set to `null`, it indicates that there is no pagination and the infinite scroll effect will not be triggered. + */ + onNext?: ((release: () => void, reset?: () => void) => Promise | void) | null; + children: React.ReactElement; +}; + +export default function InfiniteScroll({ children, onNext, offset = 20, orientation = "vertical" }: IProps) { + const isWaiting = React.useRef(false); + const elementRef = React.useRef(); + + const onChange = React.useCallback(() => { + if (!onNext) return; + const element = elementRef.current; + if (!element || isWaiting.current) return; + const { scrollTop, scrollLeft, clientHeight, clientWidth, scrollHeight, scrollWidth } = element; + let isChange = false; + + if (orientation === "vertical") isChange = scrollTop + clientHeight >= scrollHeight - offset; + if (orientation === "horizontal") isChange = scrollLeft + clientWidth >= scrollWidth - offset; + + if (isChange) { + isWaiting.current = true; + onNext(() => (isWaiting.current = false)); + } + }, [onNext, offset, orientation]); + + React.useEffect(() => onChange(), [onChange]); + + React.useEffect(() => { + const observer = new MutationObserver(onChange); + elementRef.current && observer.observe(elementRef.current, { childList: true, subtree: true }); + window.addEventListener("resize", onChange); + return () => { + observer.disconnect(); + window.removeEventListener("resize", onChange); + }; + }, [onChange]); + + if (!onNext) return children; + + const clonedChild = React.cloneElement(children, { + onScroll: onChange, + ref: elementRef, + }); + + return clonedChild; +} diff --git a/src/front/Components/Layouts/Folder/index.tsx b/src/front/Components/Layouts/Folder/index.tsx index 092ab68e..485bf6af 100644 --- a/src/front/Components/Layouts/Folder/index.tsx +++ b/src/front/Components/Layouts/Folder/index.tsx @@ -5,9 +5,10 @@ import { OfficeFolder } from "le-coffre-resources/dist/Notary"; import BasePage from "../Base"; import classes from "./classes.module.scss"; import Newletter from "@Front/Components/DesignSystem/Newsletter"; -import Button, { EButtonStyleType, EButtonVariant } from "@Front/Components/DesignSystem/Button"; +import Button, { EButtonSize, EButtonStyleType, EButtonVariant } from "@Front/Components/DesignSystem/Button"; import Tag, { ETagColor, ETagVariant } from "@Front/Components/DesignSystem/Tag"; import CircleProgress from "@Front/Components/DesignSystem/CircleProgress"; +import Table from "@Front/Components/DesignSystem/Table"; type IProps = {}; type IState = { @@ -52,6 +53,50 @@ export default class Folder extends BasePage { + + + Primary + + ), + }, + { + key: "2", + name: "Doe", + firstname: "Jane", + button: , + }, + { + key: "3", + name: "Doe", + firstname: "Jack", + button: ( + + ), + }, + ]} + />