From 1d436b8170d3002293e9d012782292f4310a5b83 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 21 Nov 2023 17:54:06 +0200 Subject: [PATCH 01/23] TSJR-314 - register admin app --- src/apps/admin/README.md | 1 + src/apps/admin/index.tsx | 1 + src/apps/admin/src/AdminApp.tsx | 22 +++++++++++++++++++++ src/apps/admin/src/admin.routes.tsx | 24 +++++++++++++++++++++++ src/apps/admin/src/config/constants.ts | 1 + src/apps/admin/src/config/index.ts | 1 + src/apps/admin/src/index.ts | 4 ++++ src/apps/admin/src/lib/index.ts | 1 + src/apps/admin/src/lib/userflow-survey.ts | 9 +++++++++ src/apps/platform/src/platform.routes.tsx | 2 ++ src/config/constants.ts | 2 ++ 11 files changed, 68 insertions(+) create mode 100644 src/apps/admin/README.md create mode 100644 src/apps/admin/index.tsx create mode 100644 src/apps/admin/src/AdminApp.tsx create mode 100644 src/apps/admin/src/admin.routes.tsx create mode 100644 src/apps/admin/src/config/constants.ts create mode 100644 src/apps/admin/src/config/index.ts create mode 100644 src/apps/admin/src/index.ts create mode 100644 src/apps/admin/src/lib/index.ts create mode 100644 src/apps/admin/src/lib/userflow-survey.ts diff --git a/src/apps/admin/README.md b/src/apps/admin/README.md new file mode 100644 index 000000000..02ce1d594 --- /dev/null +++ b/src/apps/admin/README.md @@ -0,0 +1 @@ +# Admin App diff --git a/src/apps/admin/index.tsx b/src/apps/admin/index.tsx new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/admin/index.tsx @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/admin/src/AdminApp.tsx b/src/apps/admin/src/AdminApp.tsx new file mode 100644 index 000000000..5c80d2a69 --- /dev/null +++ b/src/apps/admin/src/AdminApp.tsx @@ -0,0 +1,22 @@ +import { FC, useContext } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' +import { SharedSwrConfig } from '~/libs/shared' + +import { toolTitle } from './admin.routes' + +const AdminApp: FC<{}> = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + + return ( + + + + {getChildRoutes(toolTitle)} + + + ) +} + +export default AdminApp diff --git a/src/apps/admin/src/admin.routes.tsx b/src/apps/admin/src/admin.routes.tsx new file mode 100644 index 000000000..c1d2ae980 --- /dev/null +++ b/src/apps/admin/src/admin.routes.tsx @@ -0,0 +1,24 @@ +import { lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core' +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' + +const AdminApp: LazyLoadedComponent = lazyLoad(() => import('./AdminApp')) + +export const rootRoute: string = ( + EnvironmentConfig.SUBDOMAIN === AppSubdomain.admin ? '' : `/${AppSubdomain.admin}` +) + +export const toolTitle: string = ToolTitle.admin +export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}` + +export const adminRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + ], + domain: AppSubdomain.admin, + element: , + id: toolTitle, + rolesRequired: [UserRole.administrator], + route: rootRoute, + }, +] diff --git a/src/apps/admin/src/config/constants.ts b/src/apps/admin/src/config/constants.ts new file mode 100644 index 000000000..5a3c9091c --- /dev/null +++ b/src/apps/admin/src/config/constants.ts @@ -0,0 +1 @@ +export const CES_SURVEY_ID = '' diff --git a/src/apps/admin/src/config/index.ts b/src/apps/admin/src/config/index.ts new file mode 100644 index 000000000..f87cf0102 --- /dev/null +++ b/src/apps/admin/src/config/index.ts @@ -0,0 +1 @@ +export * from './constants' diff --git a/src/apps/admin/src/index.ts b/src/apps/admin/src/index.ts new file mode 100644 index 000000000..db1cc3082 --- /dev/null +++ b/src/apps/admin/src/index.ts @@ -0,0 +1,4 @@ +export { + adminRoutes, + rootRoute as adminRootRoute, +} from './admin.routes' diff --git a/src/apps/admin/src/lib/index.ts b/src/apps/admin/src/lib/index.ts new file mode 100644 index 000000000..eb6ba0b51 --- /dev/null +++ b/src/apps/admin/src/lib/index.ts @@ -0,0 +1 @@ +export * from './userflow-survey' diff --git a/src/apps/admin/src/lib/userflow-survey.ts b/src/apps/admin/src/lib/userflow-survey.ts new file mode 100644 index 000000000..b86e2d62e --- /dev/null +++ b/src/apps/admin/src/lib/userflow-survey.ts @@ -0,0 +1,9 @@ +import { TcUniNavFn } from 'universal-navigation' + +import { CES_SURVEY_ID } from '../config' + +declare let tcUniNav: TcUniNavFn + +export function triggerSurvey(): void { + tcUniNav('triggerFlow', CES_SURVEY_ID, {}) +} diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index eb69e5806..e321da799 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -9,6 +9,7 @@ import { profilesRoutes } from '~/apps/profiles' import { talentSearchRoutes } from '~/apps/talent-search' import { accountsRoutes } from '~/apps/accounts' import { onboardingRoutes } from '~/apps/onboarding' +import { adminRoutes } from '~/apps/admin' const Home: LazyLoadedComponent = lazyLoad(() => import('./routes/home'), 'HomePage') @@ -33,5 +34,6 @@ export const platformRoutes: Array = [ ...talentSearchRoutes, ...profilesRoutes, ...accountsRoutes, + ...adminRoutes, ...homeRoutes, ] diff --git a/src/config/constants.ts b/src/config/constants.ts index 48a4103c0..242132cb1 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,4 +1,5 @@ export enum AppSubdomain { + admin = 'admin', accounts = 'account-settings', devCenter = 'devcenter', earn = 'earn', @@ -11,6 +12,7 @@ export enum AppSubdomain { } export enum ToolTitle { + admin = 'Administration', accounts = 'Account Settings', devCenter = 'Dev Center', earn = 'Opportunity Feed', From a63c12a9828f27c4321194cb599ec71e62b0db17 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 22 Nov 2023 08:47:41 +0200 Subject: [PATCH 02/23] TSJR-314 - add skills manager routes --- src/apps/admin/src/admin.routes.tsx | 10 ++++++++ src/apps/admin/src/config/constants.ts | 1 - src/apps/admin/src/config/index.ts | 1 - src/apps/admin/src/lib/index.ts | 1 - src/apps/admin/src/lib/userflow-survey.ts | 9 ------- .../src/skills-manager/SkillsManager.tsx | 21 ++++++++++++++++ src/apps/admin/src/skills-manager/index.ts | 4 ++++ .../landing-page/LandingPage.module.scss | 0 .../landing-page/LandingPage.tsx | 16 +++++++++++++ .../src/skills-manager/landing-page/index.ts | 1 + .../skills-manager/skills-manager.routes.tsx | 24 +++++++++++++++++++ 11 files changed, 76 insertions(+), 12 deletions(-) delete mode 100644 src/apps/admin/src/config/constants.ts delete mode 100644 src/apps/admin/src/config/index.ts delete mode 100644 src/apps/admin/src/lib/index.ts delete mode 100644 src/apps/admin/src/lib/userflow-survey.ts create mode 100644 src/apps/admin/src/skills-manager/SkillsManager.tsx create mode 100644 src/apps/admin/src/skills-manager/index.ts create mode 100644 src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss create mode 100644 src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx create mode 100644 src/apps/admin/src/skills-manager/landing-page/index.ts create mode 100644 src/apps/admin/src/skills-manager/skills-manager.routes.tsx diff --git a/src/apps/admin/src/admin.routes.tsx b/src/apps/admin/src/admin.routes.tsx index c1d2ae980..e275ec8fd 100644 --- a/src/apps/admin/src/admin.routes.tsx +++ b/src/apps/admin/src/admin.routes.tsx @@ -1,6 +1,10 @@ +import { Navigate } from 'react-router-dom' + import { lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core' import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' +import { skillsManagerRootRoute, skillsManagerRoutes } from './skills-manager' + const AdminApp: LazyLoadedComponent = lazyLoad(() => import('./AdminApp')) export const rootRoute: string = ( @@ -14,6 +18,12 @@ export const adminRoutes: ReadonlyArray = [ { authRequired: true, children: [ + ...skillsManagerRoutes, + { + element: , + id: 'Default Admin Route', + route: '', + }, ], domain: AppSubdomain.admin, element: , diff --git a/src/apps/admin/src/config/constants.ts b/src/apps/admin/src/config/constants.ts deleted file mode 100644 index 5a3c9091c..000000000 --- a/src/apps/admin/src/config/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const CES_SURVEY_ID = '' diff --git a/src/apps/admin/src/config/index.ts b/src/apps/admin/src/config/index.ts deleted file mode 100644 index f87cf0102..000000000 --- a/src/apps/admin/src/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './constants' diff --git a/src/apps/admin/src/lib/index.ts b/src/apps/admin/src/lib/index.ts deleted file mode 100644 index eb6ba0b51..000000000 --- a/src/apps/admin/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './userflow-survey' diff --git a/src/apps/admin/src/lib/userflow-survey.ts b/src/apps/admin/src/lib/userflow-survey.ts deleted file mode 100644 index b86e2d62e..000000000 --- a/src/apps/admin/src/lib/userflow-survey.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { TcUniNavFn } from 'universal-navigation' - -import { CES_SURVEY_ID } from '../config' - -declare let tcUniNav: TcUniNavFn - -export function triggerSurvey(): void { - tcUniNav('triggerFlow', CES_SURVEY_ID, {}) -} diff --git a/src/apps/admin/src/skills-manager/SkillsManager.tsx b/src/apps/admin/src/skills-manager/SkillsManager.tsx new file mode 100644 index 000000000..cfd39d74e --- /dev/null +++ b/src/apps/admin/src/skills-manager/SkillsManager.tsx @@ -0,0 +1,21 @@ +import { FC, useContext } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { skillsManagerRoutes } from './skills-manager.routes' + +const SkillsManager: FC<{}> = () => { + const { getRouteElement }: RouterContextData = useContext(routerContext) + + return ( + <> + + + {skillsManagerRoutes.map(getRouteElement)} + + + ) +} + +export default SkillsManager diff --git a/src/apps/admin/src/skills-manager/index.ts b/src/apps/admin/src/skills-manager/index.ts new file mode 100644 index 000000000..563f214af --- /dev/null +++ b/src/apps/admin/src/skills-manager/index.ts @@ -0,0 +1,4 @@ +export { + skillsManagerMainRoute as skillsManagerRoutes, + rootRoute as skillsManagerRootRoute, +} from './skills-manager.routes' diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss b/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx new file mode 100644 index 000000000..ccac2eec0 --- /dev/null +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react' + +import styles from './LandingPage.module.scss' + +interface LandingPageProps { +} + +const LandingPage: FC = props => { + + return ( +
+
+ ) +} + +export default LandingPage diff --git a/src/apps/admin/src/skills-manager/landing-page/index.ts b/src/apps/admin/src/skills-manager/landing-page/index.ts new file mode 100644 index 000000000..768f0a3f6 --- /dev/null +++ b/src/apps/admin/src/skills-manager/landing-page/index.ts @@ -0,0 +1 @@ +export { default as LandingPage } from './LandingPage' diff --git a/src/apps/admin/src/skills-manager/skills-manager.routes.tsx b/src/apps/admin/src/skills-manager/skills-manager.routes.tsx new file mode 100644 index 000000000..a393ac491 --- /dev/null +++ b/src/apps/admin/src/skills-manager/skills-manager.routes.tsx @@ -0,0 +1,24 @@ +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' + +export const rootRoute: string = '/skills-manager' + +const SkillsManager: LazyLoadedComponent = lazyLoad(() => import('./SkillsManager')) +const LandingPage: LazyLoadedComponent = lazyLoad(() => import('./landing-page'), 'LandingPage') + +export const skillsManagerRoutes: ReadonlyArray = [ + { + element: , + route: '', + }, +] + +export const skillsManagerMainRoute: ReadonlyArray = [ + { + children: [ + ...skillsManagerRoutes, + ], + element: , + id: 'Skills Manager', + route: rootRoute, + }, +] From 9b4fd6d23f5a048a84ef4051ae35138cd3d128be Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Nov 2023 10:26:36 +0200 Subject: [PATCH 03/23] TSJR-314 - skill manager work --- .vscode/components.code-snippets | 42 ++++++++++-- .../src/skills-manager/SkillsManager.tsx | 5 +- .../accordion/Accordion.module.scss | 1 + .../components/accordion/Accordion.tsx | 59 ++++++++++++++++ .../accordion-item/AccordionItem.module.scss | 58 ++++++++++++++++ .../accordion-item/AccordionItem.tsx | 43 ++++++++++++ .../accordion/accordion-item/index.ts | 1 + .../components/accordion/index.ts | 2 + .../CategoriesAccordion.module.scss | 1 + .../CategoriesAccordion.tsx | 34 ++++++++++ .../components/categories-accordion/index.ts | 1 + .../src/skills-manager/components/index.ts | 4 ++ .../page-header/PageHeader.module.scss | 10 +++ .../components/page-header/PageHeader.tsx | 23 +++++++ .../components/page-header/index.ts | 1 + .../search-input/SearchInput.module.scss | 43 ++++++++++++ .../components/search-input/SearchInput.tsx | 67 +++++++++++++++++++ .../components/search-input/index.ts | 1 + .../skills-list/SkillsList.module.scss | 18 +++++ .../components/skills-list/SkillsList.tsx | 25 +++++++ .../components/skills-list/index.ts | 1 + .../landing-page/LandingPage.tsx | 30 ++++++--- .../admin/src/skills-manager/lib/index.ts | 1 + .../src/skills-manager/lib/skills.utils.ts | 38 +++++++++++ .../src/skills-manager/services/index.ts | 2 + .../services/skills-categories.service.ts | 20 ++++++ .../skills-manager/services/skills.service.ts | 20 ++++++ .../skills-manager/skills-manager.context.tsx | 54 +++++++++++++++ .../form-input/input-text/InputText.tsx | 4 +- 29 files changed, 590 insertions(+), 19 deletions(-) create mode 100644 src/apps/admin/src/skills-manager/components/accordion/Accordion.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-item/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/accordion/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx create mode 100644 src/apps/admin/src/skills-manager/components/categories-accordion/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/page-header/PageHeader.tsx create mode 100644 src/apps/admin/src/skills-manager/components/page-header/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/search-input/SearchInput.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx create mode 100644 src/apps/admin/src/skills-manager/components/search-input/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx create mode 100644 src/apps/admin/src/skills-manager/components/skills-list/index.ts create mode 100644 src/apps/admin/src/skills-manager/lib/index.ts create mode 100644 src/apps/admin/src/skills-manager/lib/skills.utils.ts create mode 100644 src/apps/admin/src/skills-manager/services/index.ts create mode 100644 src/apps/admin/src/skills-manager/services/skills-categories.service.ts create mode 100644 src/apps/admin/src/skills-manager/services/skills.service.ts create mode 100644 src/apps/admin/src/skills-manager/skills-manager.context.tsx diff --git a/.vscode/components.code-snippets b/.vscode/components.code-snippets index 55790ccc6..81f7a468e 100644 --- a/.vscode/components.code-snippets +++ b/.vscode/components.code-snippets @@ -15,7 +15,7 @@ // ], // "description": "Log output to console" // } - "[MFE] React component": { + "[PLAT] React component": { "scope": "typescript,typescriptreact", "prefix": "rfc", "body": [ @@ -28,18 +28,39 @@ "", "const ${1:ComponentName}: FC<${1:ComponentName}Props> = props => {", "", - " return (", + " return (", + "
", + "
", + " )", + "}", + "", + "export default ${1:ComponentName}", + "" + ], + "description": "Create a react functional component" + }, + "[PLAT] Simple React component": { + "scope": "typescript,typescriptreact", + "prefix": "rfc", + "body": [ + "import { FC } from 'react'", + "", + "import styles from './${1:ComponentName}.module.scss'", + "", + "interface ${1:ComponentName}Props {", + "}", + "", + "const ${1:ComponentName}: FC<${1:ComponentName}Props> = props => (", "
", "
", - " )", - "}", + ")", "", "export default ${1:ComponentName}", "" ], "description": "Create a react functional component" }, - "[MFE] export comp": { + "[PLAT] export comp": { "scope": "typescript,typescriptreact", "prefix": "exp", "body": [ @@ -48,14 +69,21 @@ ], "description": "Export module" }, - "[MFE] use state": { + "[PLAT] use state": { "scope": "typescript,typescriptreact", "prefix": "usest", "body": [ "const [$1, set$2]: [$3, Dispatch>] = useState($4)$0", ] }, - "[MFE] Storybook Template": { + "[PLAT] includes": { + "scope": "css,scss", + "prefix": "includes", + "body": [ + "@import '@libs/ui/styles/includes';", + ] + }, + "[PLAT] Storybook Template": { "scope": "typescript,typescriptreact", "prefix": "sb", "body": [ diff --git a/src/apps/admin/src/skills-manager/SkillsManager.tsx b/src/apps/admin/src/skills-manager/SkillsManager.tsx index cfd39d74e..a895c6392 100644 --- a/src/apps/admin/src/skills-manager/SkillsManager.tsx +++ b/src/apps/admin/src/skills-manager/SkillsManager.tsx @@ -4,17 +4,18 @@ import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { skillsManagerRoutes } from './skills-manager.routes' +import { SkillsManagerContext } from './skills-manager.context' const SkillsManager: FC<{}> = () => { const { getRouteElement }: RouterContextData = useContext(routerContext) return ( - <> + {skillsManagerRoutes.map(getRouteElement)} - + ) } diff --git a/src/apps/admin/src/skills-manager/components/accordion/Accordion.module.scss b/src/apps/admin/src/skills-manager/components/accordion/Accordion.module.scss new file mode 100644 index 000000000..fced2c8eb --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/Accordion.module.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/includes'; diff --git a/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx b/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx new file mode 100644 index 000000000..716bd8e93 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx @@ -0,0 +1,59 @@ +import { Children, cloneElement, FC, isValidElement, ReactNode, useCallback, useEffect, useState } from 'react' + +import { AccordionItemProps } from './accordion-item' +import styles from './Accordion.module.scss' + +interface AccordionProps { + children: JSX.Element[] | JSX.Element + defaultOpen?: boolean +} + +const Accordion: FC = props => { + const [openedSections, setOpenedSections] = useState<{[key: string]: boolean}>({}) + + const handleToggle = useCallback((key: string) => { + setOpenedSections(all => ({ ...all, [key]: !all[key] })) + }, []) + + useEffect(() => { + const openState: {[key: string]: boolean} = {} + + Children.forEach(props.children, child => { + if (!isValidElement(child)) { + return + } + + const childKey = child.key as string + openState[childKey] = child.props.open ?? props.defaultOpen + }) + + setOpenedSections(openState) + }, [props.defaultOpen, props.children]) + + const renderAccordions = (children: JSX.Element[] | JSX.Element): ReactNode => ( + Children.map(children, child => { + if (isValidElement(child)) { + const childKey = child.key as string + openedSections[childKey] = openedSections[childKey] ?? child.props.open ?? props.defaultOpen + + return cloneElement( + child, + { + open: !!openedSections[childKey], + toggle: function toggle() { handleToggle(childKey) }, + } as AccordionItemProps, + ) + } + + return child + }) + ) + + return ( +
+ {renderAccordions(props.children)} +
+ ) +} + +export default Accordion diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss new file mode 100644 index 000000000..18486e7bf --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss @@ -0,0 +1,58 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + + .wrap { + margin-top: $sp-8; + } +} + +.itemHeader { + display: flex; + align-items: center; + + cursor: pointer; +} + +.icon { + color: $turq-120; + transition: 0.2s ease-in-out; + margin-right: $sp-2; +} + +.titleBar { + display: flex; + align-items: center; + gap: $sp-2; +} + +.textLabel { + font-family: $font-roboto; + font-size: 20px; + font-weight: $font-weight-medium; + line-height: $sp-5; + letter-spacing: 0.05px; +} + +.badge { + display: block; + font-family: $font-roboto; + font-weight: $font-weight-bold; + font-size: 14px; + line-height: 22px; + padding: 0 $sp-2; + + color: $black-100; + + background: $black-10; + border-radius: $sp-4; +} + +.content { + padding: $sp-4 $sp-6; +} + +.open { + .icon { + transform: rotateZ(180deg); + } +} diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx new file mode 100644 index 000000000..eac890eda --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import styles from './AccordionItem.module.scss' + +export interface AccordionItemProps { + label?: string + badgeCount?: number + open?: boolean + toggle?: () => void + children: JSX.Element[] | JSX.Element | (() => JSX.Element[] | JSX.Element) +} + +const AccordionItem: FC = props => ( +
+
+ + + +
+ {props.label && ( +
+ {props.label} +
+ )} + {props.badgeCount !== undefined && ( +
+ {props.badgeCount} +
+ )} +
+
+ {props.open && ( +
+ {typeof props.children === 'function' ? props.children() : props.children} +
+ )} +
+) + +export default AccordionItem diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/index.ts b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/index.ts new file mode 100644 index 000000000..293a3aba1 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/index.ts @@ -0,0 +1 @@ +export { default as AccordionItem, type AccordionItemProps } from './AccordionItem' diff --git a/src/apps/admin/src/skills-manager/components/accordion/index.ts b/src/apps/admin/src/skills-manager/components/accordion/index.ts new file mode 100644 index 000000000..e4d62666a --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/index.ts @@ -0,0 +1,2 @@ +export { default as Accordion } from './Accordion' +export * from './accordion-item' diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.module.scss b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.module.scss new file mode 100644 index 000000000..fced2c8eb --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.module.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/includes'; diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx new file mode 100644 index 000000000..159dd7510 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react' + +import { CategoryGroup } from '../../lib' +import { Accordion, AccordionItem } from '../accordion' +import { SkillsList } from '../skills-list' + +import styles from './CategoriesAccordion.module.scss' + +interface CategoriesAccordionProps { + categories: CategoryGroup[] + defaultOpen?: boolean +} + +const CategoriesAccordion: FC = props => { + const d = 0 + + return ( + + {props.categories.map(category => ( + + {() => ( + + )} + + ))} + + ) +} + +export default CategoriesAccordion diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/index.ts b/src/apps/admin/src/skills-manager/components/categories-accordion/index.ts new file mode 100644 index 000000000..a3edebc19 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/index.ts @@ -0,0 +1 @@ +export { default as CategoriesAccordion } from './CategoriesAccordion' diff --git a/src/apps/admin/src/skills-manager/components/index.ts b/src/apps/admin/src/skills-manager/components/index.ts new file mode 100644 index 000000000..9fc84d14a --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/index.ts @@ -0,0 +1,4 @@ +export * from './accordion' +export * from './categories-accordion' +export * from './page-header' +export * from './search-input' diff --git a/src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss new file mode 100644 index 000000000..eaa8b198d --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss @@ -0,0 +1,10 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + padding: $sp-8 0 0; +} + +.inner { + display: flex; + align-items: center; +} diff --git a/src/apps/admin/src/skills-manager/components/page-header/PageHeader.tsx b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.tsx new file mode 100644 index 000000000..8735a93da --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.tsx @@ -0,0 +1,23 @@ +import { FC, PropsWithChildren } from 'react' + +import styles from './PageHeader.module.scss' + +interface PageHeaderProps extends PropsWithChildren { + title: string +} + +const PageHeader: FC = props => ( +
+
+

{props.title}

+ +
+ {props.children} +
+
+ +
+
+) + +export default PageHeader diff --git a/src/apps/admin/src/skills-manager/components/page-header/index.ts b/src/apps/admin/src/skills-manager/components/page-header/index.ts new file mode 100644 index 000000000..6f9433ba8 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/page-header/index.ts @@ -0,0 +1 @@ +export { default as PageHeader } from './PageHeader' diff --git a/src/apps/admin/src/skills-manager/components/search-input/SearchInput.module.scss b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.module.scss new file mode 100644 index 000000000..869d90b4b --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.module.scss @@ -0,0 +1,43 @@ +@import '@libs/ui/styles/includes'; + +.inputWrapper { + display: flex; + align-items: center; + gap: $sp-1; +} + +.inputText { + color: $black-60; + box-sizing: border-box; + border: 0; + width: 100%; + padding: 0; + margin: 0; + height: auto; + border-radius: 0; + + &:focus { + box-shadow: none; + border: none; + outline: none; + color: $black-100; + } + + &::placeholder { + color: $black-60; + opacity: 1; + text-transform: none; + } +} + +.inputIcon { + color: $black-60; +} + +.clearIcon { + cursor: pointer; + transition: 0.2s ease-in-out; + &:hover { + color: $black-100; + } +} diff --git a/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx new file mode 100644 index 000000000..6c895ef9d --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx @@ -0,0 +1,67 @@ +import { FC, useEffect, useMemo, useState } from 'react' +import { debounce } from 'lodash' +import classNames from 'classnames' + +import { IconOutline, InputWrapper } from '~/libs/ui' + +import styles from './SearchInput.module.scss' + +interface SearchInputProps { + value: string + onChange: (t: string) => void +} + +const SearchInput: FC = props => { + const [value, setValue] = useState(props.value) + + const debouncedOnChange = useMemo(() => debounce((newValue): void => { + props.onChange.call(undefined, newValue ?? '') + }, 300), [props.onChange]) + + function handleOnChange(ev: any): void { + const newValue = ev.target.value ?? '' + setValue(newValue) + debouncedOnChange(newValue) + } + + useEffect(() => { + if (props.value !== value) { + setValue(props.value) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.value]) + + return ( +
+ +
+
+ +
+ + {props.value && ( +
+ +
+ )} +
+
+
+ ) +} + +export default SearchInput diff --git a/src/apps/admin/src/skills-manager/components/search-input/index.ts b/src/apps/admin/src/skills-manager/components/search-input/index.ts new file mode 100644 index 000000000..73dcba1b6 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/search-input/index.ts @@ -0,0 +1 @@ +export { default as SearchInput } from './SearchInput' diff --git a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss new file mode 100644 index 000000000..fe112dce7 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss @@ -0,0 +1,18 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + column-count: 4; +} + +.listWrap { + display: flex; + flex-direction: column; +} + +.skillItem { + padding: $sp-1 $sp-2; + + + .skillItem { + margin-top: $sp-2; + } +} diff --git a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx new file mode 100644 index 000000000..e3b7dbc22 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react' + +import { StandardizedSkill } from '../../services' + +import styles from './SkillsList.module.scss' + +interface SkillsListProps { + skills: StandardizedSkill[] +} + +const SkillsList: FC = props => ( +
+
    + {props.skills.map(skill => ( +
  • +
    + {skill.name} +
    +
  • + ))} +
+
+) + +export default SkillsList diff --git a/src/apps/admin/src/skills-manager/components/skills-list/index.ts b/src/apps/admin/src/skills-manager/components/skills-list/index.ts new file mode 100644 index 000000000..ee1dfc41d --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skills-list/index.ts @@ -0,0 +1 @@ +export { default as SkillsList } from './SkillsList' diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx index ccac2eec0..26e411564 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -1,16 +1,30 @@ import { FC } from 'react' -import styles from './LandingPage.module.scss' +import { ContentLayout, PageTitle } from '~/libs/ui' -interface LandingPageProps { -} +import { CategoriesAccordion, PageHeader, SearchInput } from '../components' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../skills-manager.context' + +const LandingPage: FC<{}> = () => { + const { + groupedSkills, + setSkillsFilter, + skillsFilter, + }: SkillsManagerContextValue = useSkillsManagerContext() + + return ( + + Skills Manager | Admin + -const LandingPage: FC = props => { + - return ( -
-
- ) + +
+ ) } export default LandingPage diff --git a/src/apps/admin/src/skills-manager/lib/index.ts b/src/apps/admin/src/skills-manager/lib/index.ts new file mode 100644 index 000000000..68cf4184f --- /dev/null +++ b/src/apps/admin/src/skills-manager/lib/index.ts @@ -0,0 +1 @@ +export * from './skills.utils' diff --git a/src/apps/admin/src/skills-manager/lib/skills.utils.ts b/src/apps/admin/src/skills-manager/lib/skills.utils.ts new file mode 100644 index 000000000..45b506901 --- /dev/null +++ b/src/apps/admin/src/skills-manager/lib/skills.utils.ts @@ -0,0 +1,38 @@ +import { escapeRegExp, orderBy } from 'lodash' + +import { StandardizedSkill, StandardizedSkillCategory } from '../services' + +export interface CategoryGroup extends StandardizedSkillCategory { + skills: StandardizedSkill[] +} + +export const groupSkillsByCategories = (skills: StandardizedSkill[]): CategoryGroup[] => { + const allGroups = skills.reduce((grouped, skill) => { + if (!grouped[skill.category.id]) { + grouped[skill.category.id] = { + ...skill.category, + skills: [], + } + } + + grouped[skill.category.id].skills.push(skill) + + return grouped + }, {} as {[key: string]: CategoryGroup}) + + // sort groups, then sort skills, all by name ascending order + const sortedGroups = orderBy(Object.values(allGroups), 'name', 'asc') + const allSorted = sortedGroups.map(group => ({ + ...group, + skills: orderBy(group.skills, 'name', 'asc'), + })) + + return allSorted +} + +export const findSkillsMatches = (skills: StandardizedSkill[], skillsFilter: string): StandardizedSkill[] => { + const filterRegex = new RegExp(escapeRegExp(skillsFilter), 'i') + return skills.filter(skill => ( + filterRegex.test(skill.name) || filterRegex.test(skill.category.name) + )) +} diff --git a/src/apps/admin/src/skills-manager/services/index.ts b/src/apps/admin/src/skills-manager/services/index.ts new file mode 100644 index 000000000..701264de5 --- /dev/null +++ b/src/apps/admin/src/skills-manager/services/index.ts @@ -0,0 +1,2 @@ +export * from './skills.service' +export * from './skills-categories.service' diff --git a/src/apps/admin/src/skills-manager/services/skills-categories.service.ts b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts new file mode 100644 index 000000000..86faa0ee3 --- /dev/null +++ b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts @@ -0,0 +1,20 @@ +import useSWR, { SWRResponse } from 'swr' + +import { EnvironmentConfig } from '~/config' +import { UserSkillCategory, xhrGetAsync } from '~/libs/core' + +const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + +export interface StandardizedSkillCategory extends UserSkillCategory {} + +export const useFetchCategories = (): StandardizedSkillCategory[] | undefined => { + const url = `${baseUrl}/categories?perPage=9999` + + const { data: allCategories }: SWRResponse + = useSWR(url, xhrGetAsync, { + refreshInterval: 0, + revalidateOnFocus: false, + }) + + return allCategories +} diff --git a/src/apps/admin/src/skills-manager/services/skills.service.ts b/src/apps/admin/src/skills-manager/services/skills.service.ts new file mode 100644 index 000000000..b15cd60dd --- /dev/null +++ b/src/apps/admin/src/skills-manager/services/skills.service.ts @@ -0,0 +1,20 @@ +import useSWR, { SWRResponse } from 'swr' + +import { EnvironmentConfig } from '~/config' +import { UserSkill, xhrGetAsync } from '~/libs/core' + +const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + +export interface StandardizedSkill extends UserSkill {} + +export const useFetchSkills = (): StandardizedSkill[] | undefined => { + const url = `${baseUrl}/skills?perPage=9999` + + const { data: allSkills }: SWRResponse + = useSWR(url, xhrGetAsync, { + refreshInterval: 0, + revalidateOnFocus: false, + }) + + return allSkills +} diff --git a/src/apps/admin/src/skills-manager/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/skills-manager.context.tsx new file mode 100644 index 000000000..ef9545257 --- /dev/null +++ b/src/apps/admin/src/skills-manager/skills-manager.context.tsx @@ -0,0 +1,54 @@ +import { createContext, FC, ReactNode, useContext, useMemo, useState } from 'react' +import { noop } from 'lodash' + +import { StandardizedSkill, useFetchSkills } from './services' +import { CategoryGroup, findSkillsMatches, groupSkillsByCategories } from './lib' + +export interface SkillsManagerContextValue { + skillsFilter: string + setSkillsFilter: (filter: string) => void + skillsList: StandardizedSkill[] + groupedSkills: CategoryGroup[] +} + +const SkillsManagerRC = createContext({ + groupedSkills: [], + setSkillsFilter: noop, + skillsFilter: '', + skillsList: [], +}) + +interface SkillsManagerContextProps { + children?: ReactNode +} + +export const SkillsManagerContext: FC = props => { + const [skillsFilter, setSkillsFilter] = useState('') + + const allSkills = useFetchSkills() + + const skills = useMemo(() => findSkillsMatches(allSkills ?? [], skillsFilter), [allSkills, skillsFilter]) + + // !!TODO: this operation might be expensive, look into caching it + // !!!TODO: CERTAIN MEMORY LEAK, INVESTIGATE + const groupedSkills = useMemo(() => groupSkillsByCategories(skills), [skills]) + + const contextValue = useMemo(() => ({ + groupedSkills, + setSkillsFilter, + skillsFilter, + skillsList: skills, + }), [groupedSkills, skills, skillsFilter]) + + return ( + + {props.children} + + ) +} + +export const useSkillsManagerContext = (): SkillsManagerContextValue => ( + useContext(SkillsManagerRC) +) diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx index 7ac93cf35..01f12dec0 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx @@ -26,7 +26,7 @@ export interface InputTextProps { readonly placeholder?: string readonly readonly?: boolean readonly spellCheck?: boolean - readonly tabIndex: number + readonly tabIndex?: number readonly tooltip?: FormInputTooltipOptions readonly type: InputTextTypes readonly value?: InputValue @@ -62,7 +62,7 @@ const InputText: FC = (props: InputTextProps) => { placeholder={props.placeholder} readOnly={props.readonly} spellCheck={!!props.spellCheck} - tabIndex={props.tabIndex} + tabIndex={props.tabIndex ?? -1} type={props.type || 'text'} /> ) From 01a2ad04cdbbde31a64b42aa7c1e8d318cdd5d05 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Nov 2023 12:48:20 +0200 Subject: [PATCH 04/23] TSJR-314 - optimize accordion --- .../components/accordion/Accordion.tsx | 49 ++++++++++++----- .../accordion-item/AccordionItem.tsx | 54 ++++++++++--------- .../CategoriesAccordion.tsx | 37 ++++++------- .../landing-page/LandingPage.tsx | 2 +- .../skills-manager/skills-manager.context.tsx | 2 - 5 files changed, 82 insertions(+), 62 deletions(-) diff --git a/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx b/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx index 716bd8e93..cb7e3f41c 100644 --- a/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx +++ b/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx @@ -1,4 +1,14 @@ -import { Children, cloneElement, FC, isValidElement, ReactNode, useCallback, useEffect, useState } from 'react' +import { + Children, + cloneElement, + FC, + isValidElement, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { AccordionItemProps } from './accordion-item' import styles from './Accordion.module.scss' @@ -8,26 +18,39 @@ interface AccordionProps { defaultOpen?: boolean } +function computeOpenSectionsState(props: AccordionProps): {[key: string]: boolean} { + const newOpenState: {[key: string]: boolean} = {} + + Children.forEach(props.children, child => { + if (!isValidElement(child)) { + return + } + + const childKey = child.key as string + newOpenState[childKey] = child.props.open ?? props.defaultOpen + }) + + return newOpenState +} + const Accordion: FC = props => { + const prevProps = useRef({ ...props }) const [openedSections, setOpenedSections] = useState<{[key: string]: boolean}>({}) const handleToggle = useCallback((key: string) => { setOpenedSections(all => ({ ...all, [key]: !all[key] })) }, []) - useEffect(() => { - const openState: {[key: string]: boolean} = {} + // check if props have changed and update the openedSections synchronously + if (prevProps.current.children !== props.children || prevProps.current.defaultOpen !== props.defaultOpen) { + prevProps.current = { ...props } + Object.assign(openedSections, computeOpenSectionsState(props)) + } - Children.forEach(props.children, child => { - if (!isValidElement(child)) { - return - } - - const childKey = child.key as string - openState[childKey] = child.props.open ?? props.defaultOpen - }) - - setOpenedSections(openState) + // use an effect to make sure the changes are propagated in the state + useEffect(() => { + setOpenedSections(computeOpenSectionsState(props)) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.defaultOpen, props.children]) const renderAccordions = (children: JSX.Element[] | JSX.Element): ReactNode => ( diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx index eac890eda..1a814d049 100644 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useMemo } from 'react' import classNames from 'classnames' import { IconOutline } from '~/libs/ui' @@ -13,31 +13,35 @@ export interface AccordionItemProps { children: JSX.Element[] | JSX.Element | (() => JSX.Element[] | JSX.Element) } -const AccordionItem: FC = props => ( -
-
- - - -
- {props.label && ( -
- {props.label} -
- )} - {props.badgeCount !== undefined && ( -
- {props.badgeCount} -
- )} -
+const AccordionItem: FC = props => { + const content = useMemo(() => (!props.open ? <> : ( +
+ {typeof props.children === 'function' ? props.children.call(undefined) : props.children}
- {props.open && ( -
- {typeof props.children === 'function' ? props.children() : props.children} + )), [props.children, props.open]) + + return ( +
+
+ + + +
+ {props.label && ( +
+ {props.label} +
+ )} + {props.badgeCount !== undefined && ( +
+ {props.badgeCount} +
+ )} +
- )} -
-) + {content} +
+ ) +} export default AccordionItem diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx index 159dd7510..ebd7d70c1 100644 --- a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx @@ -4,31 +4,26 @@ import { CategoryGroup } from '../../lib' import { Accordion, AccordionItem } from '../accordion' import { SkillsList } from '../skills-list' -import styles from './CategoriesAccordion.module.scss' - interface CategoriesAccordionProps { categories: CategoryGroup[] defaultOpen?: boolean } -const CategoriesAccordion: FC = props => { - const d = 0 - - return ( - - {props.categories.map(category => ( - - {() => ( - - )} - - ))} - - ) -} +const CategoriesAccordion: FC = props => ( + + {props.categories.map(category => ( + + {() => ( + + )} + + ))} + +) export default CategoriesAccordion diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx index 26e411564..11a379a4c 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -20,8 +20,8 @@ const LandingPage: FC<{}> = () => { ) diff --git a/src/apps/admin/src/skills-manager/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/skills-manager.context.tsx index ef9545257..61163186a 100644 --- a/src/apps/admin/src/skills-manager/skills-manager.context.tsx +++ b/src/apps/admin/src/skills-manager/skills-manager.context.tsx @@ -29,8 +29,6 @@ export const SkillsManagerContext: FC = props => { const skills = useMemo(() => findSkillsMatches(allSkills ?? [], skillsFilter), [allSkills, skillsFilter]) - // !!TODO: this operation might be expensive, look into caching it - // !!!TODO: CERTAIN MEMORY LEAK, INVESTIGATE const groupedSkills = useMemo(() => groupSkillsByCategories(skills), [skills]) const contextValue = useMemo(() => ({ From e26a983018c692e72f739887e8bea829edcbcc0b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Nov 2023 16:19:25 +0200 Subject: [PATCH 05/23] TSJR-314 - category modal --- .../accordion-item/AccordionItem.tsx | 10 +++ .../accordion-menu/AccordionMenu.module.scss | 1 + .../accordion-menu/AccordionMenu.tsx | 55 ++++++++++++++ .../accordion/accordion-menu/index.ts | 1 + .../CategoriesAccordion.tsx | 56 ++++++++++----- .../category-modal/CategoryModal.module.scss | 7 ++ .../category-modal/CategoryModal.tsx | 71 +++++++++++++++++++ .../category-modal/category-form.config.ts | 55 ++++++++++++++ .../components/category-modal/index.ts | 1 + .../src/skills-manager/components/index.ts | 1 + .../landing-page/LandingPage.tsx | 11 ++- .../services/skills-categories.service.ts | 17 +++-- .../skills-manager/skills-manager.context.tsx | 17 ++++- src/libs/core/lib/profile/user-skill.model.ts | 1 + .../input-textarea/InputTextarea.tsx | 6 +- .../validator.functions.ts | 7 +- 16 files changed, 289 insertions(+), 28 deletions(-) create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx create mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx create mode 100644 src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts create mode 100644 src/apps/admin/src/skills-manager/components/category-modal/index.ts diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx index 1a814d049..d32f20ac0 100644 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx @@ -3,6 +3,8 @@ import classNames from 'classnames' import { IconOutline } from '~/libs/ui' +import { AccordionMenu, AccordionMenuItem } from '../accordion-menu' + import styles from './AccordionItem.module.scss' export interface AccordionItemProps { @@ -11,6 +13,8 @@ export interface AccordionItemProps { open?: boolean toggle?: () => void children: JSX.Element[] | JSX.Element | (() => JSX.Element[] | JSX.Element) + menuActions: AccordionMenuItem[] + onMenuAction: (a: string) => void } const AccordionItem: FC = props => { @@ -37,6 +41,12 @@ const AccordionItem: FC = props => { {props.badgeCount}
)} + {props.menuActions?.length > 0 && ( + + )}
{content} diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss new file mode 100644 index 000000000..fced2c8eb --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/includes'; diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx new file mode 100644 index 000000000..77ae6a210 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx @@ -0,0 +1,55 @@ +import { FC, ReactNode, useState } from 'react' + +import { IconOutline } from '~/libs/ui' + +import styles from './AccordionMenu.module.scss' + +export interface AccordionMenuItem { + label: string + action: string +} + +interface AccordionMenuProps { + items: AccordionMenuItem[] + onAction: (action: string) => void + children?: ReactNode +} + +const AccordionMenu: FC = props => { + const [isMenuVisible, setIsMenuVisible] = useState(false) + + function toggleMenu(): void { + setIsMenuVisible(isVisible => !isVisible) + } + + function renderDropdownMenu(): ReactNode { + return ( +
    + {props.items.map(item => ( +
  • + + {item.label} + +
  • + ))} +
+ ) + } + + return ( +
+
+ {props.children || ( + + )} +
+ {isMenuVisible && renderDropdownMenu()} +
+ ) +} + +export default AccordionMenu diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts new file mode 100644 index 000000000..8797c2c22 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts @@ -0,0 +1 @@ +export { default as AccordionMenu, type AccordionMenuItem } from './AccordionMenu' diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx index ebd7d70c1..cab66a4b6 100644 --- a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx @@ -3,27 +3,51 @@ import { FC } from 'react' import { CategoryGroup } from '../../lib' import { Accordion, AccordionItem } from '../accordion' import { SkillsList } from '../skills-list' +import { AccordionMenuItem } from '../accordion/accordion-menu' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../skills-manager.context' interface CategoriesAccordionProps { categories: CategoryGroup[] defaultOpen?: boolean } -const CategoriesAccordion: FC = props => ( - - {props.categories.map(category => ( - - {() => ( - - )} - - ))} - -) +const groupActions: AccordionMenuItem[] = [ + { action: 'edit:category', label: 'Edit Category' }, + { action: 'edit:skills:bulk', label: 'Bulk Edit Skills' }, +] + +const CategoriesAccordion: FC = props => { + const { + setEditCategory, + }: SkillsManagerContextValue = useSkillsManagerContext() + + function handleMenuActions(action: string, category: CategoryGroup): void { + switch (action) { + case 'edit:category': + setEditCategory(category) + break + default: break + } + } + + return ( + + {props.categories.map(category => ( + + {() => ( + + )} + + ))} + + ) +} export default CategoriesAccordion diff --git a/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss new file mode 100644 index 000000000..5fcbf15e8 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss @@ -0,0 +1,7 @@ +@import '@libs/ui/styles/includes'; + +.content { + > form > :global(.page-divider) { + display: none; + } +} diff --git a/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx new file mode 100644 index 000000000..27e7c9d63 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx @@ -0,0 +1,71 @@ +import { ChangeEvent, FC, MutableRefObject, ReactNode, useCallback, useMemo, useRef, useState } from 'react' + +import { BaseModal, Button, Form, FormInputModel, FormValue, InputText, InputTextarea, LoadingSpinner, formGetInputModel } from '~/libs/ui' + +import { StandardizedSkillCategory, saveStandardizedSkillCategory } from '../../services' + +import { categoryFormDef, CategoryFormField } from './category-form.config' +import styles from './CategoryModal.module.scss' + +interface CategoryModalProps { + category: StandardizedSkillCategory + onClose: () => void +} + +const CategoryModal: FC = props => { + const action = props.category?.id ? 'edit' : 'add' + + const [loading, setLoading] = useState(false) + + const generateRequest = useCallback((inputs: ReadonlyArray): FormValue => ({ + description: formGetInputModel(inputs, CategoryFormField.description).value as string, + id: props.category.id as string, + name: formGetInputModel(inputs, CategoryFormField.name).value as string, + }), []) + + const saveAsync = useCallback(async (request: FormValue): Promise => { + setLoading(true) + + return saveStandardizedSkillCategory(request as unknown as StandardizedSkillCategory) + .then((createdBadge: StandardizedSkillCategory) => { + console.log('saved', createdBadge) + + props.onClose.call(undefined) + }) + }, [props.onClose]) + + const formDef = useMemo(() => ({ + ...categoryFormDef, + buttons: { + primaryGroup: categoryFormDef.buttons.primaryGroup.map(btn => ({ + ...btn, + ...(btn.isSubmit ? {} : { onClick: props.onClose }), + })), + }, + }), [props.onClose]) + + function renderForm(): ReactNode { + return ( +
+ ) + } + + return ( + + {renderForm()} + + + ) +} + +export default CategoryModal diff --git a/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts b/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts new file mode 100644 index 000000000..daae8c65c --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts @@ -0,0 +1,55 @@ +import { FormDefinition, validatorRequired } from '~/libs/ui' + +export enum CategoryFormField { + name = 'name', + description = 'description', +} + +export const categoryFormDef: FormDefinition = { + buttons: { + primaryGroup: [ + { + buttonStyle: 'secondary', + isSubmit: false, + label: 'Cancel', + size: 'lg', + type: 'button', + }, + { + buttonStyle: 'primary', + isSubmit: true, + label: 'Save', + size: 'lg', + type: 'submit', + }, + ], + }, + groups: [ + { + inputs: [ + { + label: 'Name', + name: CategoryFormField.name, + placeholder: 'Enter category name', + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Description', + name: CategoryFormField.description, + type: 'textarea', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + ], + }, + ], + successMessage: false, +} diff --git a/src/apps/admin/src/skills-manager/components/category-modal/index.ts b/src/apps/admin/src/skills-manager/components/category-modal/index.ts new file mode 100644 index 000000000..0f3a1ce3d --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/category-modal/index.ts @@ -0,0 +1 @@ +export { default as CategoryModal } from './CategoryModal' diff --git a/src/apps/admin/src/skills-manager/components/index.ts b/src/apps/admin/src/skills-manager/components/index.ts index 9fc84d14a..cb75eadd8 100644 --- a/src/apps/admin/src/skills-manager/components/index.ts +++ b/src/apps/admin/src/skills-manager/components/index.ts @@ -1,4 +1,5 @@ export * from './accordion' export * from './categories-accordion' +export * from './category-modal' export * from './page-header' export * from './search-input' diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx index 11a379a4c..3c52532f6 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -2,7 +2,7 @@ import { FC } from 'react' import { ContentLayout, PageTitle } from '~/libs/ui' -import { CategoriesAccordion, PageHeader, SearchInput } from '../components' +import { CategoriesAccordion, CategoryModal, PageHeader, SearchInput } from '../components' import { SkillsManagerContextValue, useSkillsManagerContext } from '../skills-manager.context' const LandingPage: FC<{}> = () => { @@ -10,6 +10,8 @@ const LandingPage: FC<{}> = () => { groupedSkills, setSkillsFilter, skillsFilter, + editCategory, + setEditCategory, }: SkillsManagerContextValue = useSkillsManagerContext() return ( @@ -23,6 +25,13 @@ const LandingPage: FC<{}> = () => { defaultOpen={!!skillsFilter} categories={groupedSkills} /> + + {!!editCategory && ( + + )} ) } diff --git a/src/apps/admin/src/skills-manager/services/skills-categories.service.ts b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts index 86faa0ee3..9fc322cd7 100644 --- a/src/apps/admin/src/skills-manager/services/skills-categories.service.ts +++ b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts @@ -1,14 +1,15 @@ +import { omit } from 'lodash' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { UserSkillCategory, xhrGetAsync } from '~/libs/core' +import { UserSkillCategory, xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' -const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` +const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills/categories` export interface StandardizedSkillCategory extends UserSkillCategory {} export const useFetchCategories = (): StandardizedSkillCategory[] | undefined => { - const url = `${baseUrl}/categories?perPage=9999` + const url = `${baseUrl}?perPage=9999` const { data: allCategories }: SWRResponse = useSWR(url, xhrGetAsync, { @@ -16,5 +17,13 @@ export const useFetchCategories = (): StandardizedSkillCategory[] | undefined => revalidateOnFocus: false, }) - return allCategories + return allCategories ?? [] +} + +export const saveStandardizedSkillCategory = (category: StandardizedSkillCategory) +: Promise => { + const xhrSaveAsyncFn = category.id ? xhrPutAsync : xhrPostAsync + const url = `${baseUrl}${category.id ? `/${category.id}` : ''}` + + return xhrSaveAsyncFn(url, omit(category, 'id')) } diff --git a/src/apps/admin/src/skills-manager/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/skills-manager.context.tsx index 61163186a..318e760e9 100644 --- a/src/apps/admin/src/skills-manager/skills-manager.context.tsx +++ b/src/apps/admin/src/skills-manager/skills-manager.context.tsx @@ -1,18 +1,24 @@ import { createContext, FC, ReactNode, useContext, useMemo, useState } from 'react' -import { noop } from 'lodash' +import { noop, orderBy } from 'lodash' -import { StandardizedSkill, useFetchSkills } from './services' +import { StandardizedSkill, StandardizedSkillCategory, useFetchCategories, useFetchSkills } from './services' import { CategoryGroup, findSkillsMatches, groupSkillsByCategories } from './lib' export interface SkillsManagerContextValue { + allCategories: StandardizedSkillCategory[] skillsFilter: string setSkillsFilter: (filter: string) => void skillsList: StandardizedSkill[] groupedSkills: CategoryGroup[] + editCategory: CategoryGroup | undefined + setEditCategory: (group?: CategoryGroup) => void } const SkillsManagerRC = createContext({ + allCategories: [], + editCategory: undefined, groupedSkills: [], + setEditCategory: noop, setSkillsFilter: noop, skillsFilter: '', skillsList: [], @@ -24,19 +30,24 @@ interface SkillsManagerContextProps { export const SkillsManagerContext: FC = props => { const [skillsFilter, setSkillsFilter] = useState('') + const [editCategory, setEditCategory] = useState() const allSkills = useFetchSkills() + const allCategories = useFetchCategories() const skills = useMemo(() => findSkillsMatches(allSkills ?? [], skillsFilter), [allSkills, skillsFilter]) const groupedSkills = useMemo(() => groupSkillsByCategories(skills), [skills]) const contextValue = useMemo(() => ({ + allCategories: orderBy(allCategories ?? [], 'name', 'asc'), + editCategory, groupedSkills, + setEditCategory, setSkillsFilter, skillsFilter, skillsList: skills, - }), [groupedSkills, skills, skillsFilter]) + }), [allCategories, editCategory, groupedSkills, skills, skillsFilter]) return ( ) => void + readonly onBlur?: (event: FocusEvent) => void readonly onChange: (event: FocusEvent) => void readonly placeholder?: string readonly spellCheck?: boolean - readonly tabIndex: number + readonly tabIndex?: number readonly value?: string | number } @@ -41,7 +41,7 @@ const InputTextarea: FC = (props: InputTextareaProps) => ( onChange={props.onChange} placeholder={props.placeholder} spellCheck={!!props.spellCheck} - tabIndex={props.tabIndex} + tabIndex={props.tabIndex ?? -1} /> ) diff --git a/src/libs/ui/lib/components/form/validator-functions/validator.functions.ts b/src/libs/ui/lib/components/form/validator-functions/validator.functions.ts index 613287efd..2b05be1b7 100644 --- a/src/libs/ui/lib/components/form/validator-functions/validator.functions.ts +++ b/src/libs/ui/lib/components/form/validator-functions/validator.functions.ts @@ -114,7 +114,12 @@ export function matchOther( } export function required(value: InputValue): string | undefined { - return (value === undefined || value === '' || !(value as FileList).length) ? 'Required' : undefined + return ( + value === undefined + || value === null + || value === '' + || !(value as FileList).length + ) ? 'Required' : undefined } export function requiredIfOther( From b79fa382377f9afba2cbc406856fc9ddf29a5ed6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Nov 2023 16:25:27 +0200 Subject: [PATCH 06/23] TSJR-314 - implement new select input and use it in form definition --- .../form/form-groups/FormGroups.tsx | 23 ++- .../form/form-groups/form-input/index.ts | 1 + .../InputSelectReact.module.scss | 140 ++++++++++++++++++ .../input-select-react/InputSelectReact.tsx | 128 ++++++++++++++++ .../form-input/input-select-react/index.ts | 1 + .../form/form-groups/form-radio/index.tsx | 1 + .../lib/components/form/form-input.model.ts | 5 +- 7 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/index.ts diff --git a/src/libs/ui/lib/components/form/form-groups/FormGroups.tsx b/src/libs/ui/lib/components/form/form-groups/FormGroups.tsx index 5c18fff66..e0bf4abf6 100644 --- a/src/libs/ui/lib/components/form/form-groups/FormGroups.tsx +++ b/src/libs/ui/lib/components/form/form-groups/FormGroups.tsx @@ -3,10 +3,17 @@ import { ChangeEvent, FocusEvent } from 'react' import { PageDivider } from '../../page-divider' import { FormDefinition } from '../form-definition.model' import { FormGroup } from '../form-group.model' -import { FormInputModel } from '../form-input.model' +import { FormInputModel, FormRadioButtonOption } from '../form-input.model' import { FormCardSet } from './form-card-set' -import { InputImagePicker, InputRating, InputText, InputTextarea } from './form-input' +import { + InputImagePicker, + InputRating, + InputSelectOption, + InputSelectReact, + InputText, + InputTextarea, +} from './form-input' import { FormInputRow } from './form-input-row' import { InputTextTypes } from './form-input/input-text/InputText' import FormGroupItem from './form-group-item/FormGroupItem' @@ -75,6 +82,7 @@ const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsPr {...input} onChange={props.onChange} value={input.value} + options={input.options as FormRadioButtonOption[]} /> ) break @@ -105,6 +113,17 @@ const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsPr /> ) break + case 'select': + inputElement = ( + + ) + break default: inputElement = ( input { + min-width: 72px!important; + } + } + + &:global(__single-value) { + @extend .body-small; + color: $black-60; + } +} + +.sel { + &:global(__menu-portal).sel:global(__menu-portal) { + z-index: 1001; + } + &:global(__menu) { + width: 100%; + background-color: $tc-white; + border-radius: 4px; + box-shadow: 0px 4px 4px 0px rgba(0,0,0,0.25); + margin-bottom: 5px; + margin-top: 5px; + border: 1px solid $black-40; + &:global(-list) { + max-height: 300px; + overflow-y: auto; + position: relative; + -webkit-overflow-scrolling: touch; + padding: 8px 0; + } + &:global(-notice) { + text-align: center; + color: #999; + padding: 8px 12px; + } + } + &:global(__option) { + cursor: default; + display: block; + width: 100%; + user-select: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + background-color: transparent; + color: $black-100; + padding: 8px 16px; + + font-size: 16px; + line-height: 24px; + + } + &:global(__option).sel:global(__option) { + &:active, + &:global(--is-focused), + &:global(--is-selected) { + color: $tc-white; + } + + &:global(--is-focused) { + background-color: rgba($turq-160, 0.5); + } + &:active { + background-color: rgba($turq-160, 0.8); + } + &:global(--is-selected) { + background-color: $turq-160; + } + } +} diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx new file mode 100644 index 000000000..ceb420299 --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx @@ -0,0 +1,128 @@ +import { + ChangeEvent, + FC, + MutableRefObject, + ReactNode, + useMemo, + useRef, +} from 'react' +import { find } from 'lodash' +import ReactSelect, { GroupBase, OptionsOrGroups } from 'react-select' +import classNames from 'classnames' + +import { InputWrapper } from '../input-wrapper' + +import styles from './InputSelectReact.module.scss' + +export interface InputSelectOption { + label?: ReactNode + value: string +} + +interface InputSelectReactProps { + readonly className?: string + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string + readonly name: string + readonly onChange: (event: ChangeEvent) => void + readonly options: OptionsOrGroups> + readonly placeholder?: string + readonly tabIndex?: number + readonly value?: string +} + +/** + * Finds the nearest ancestor element starting from the given element. + * + * @param {HTMLElement} el - The HTML element from which to start searching for the ancestor. + * @returns {HTMLElement | undefined} The nearest ancestor element, or undefined if not found. + */ +const findParentFrom = (el: HTMLElement): HTMLElement | undefined => { + // If the current element has no parent, return undefined + if (!el.parentElement) { + return undefined + } + + // If the parent element is a , return it + if (el.parentElement.nodeName === 'FORM') { + return el.parentElement + } + + // Recursively search for the ancestor in the parent's hierarchy + return findParentFrom(el.parentElement) +} + +/** + * Form component that uses for rendering, + * but also accomodates the utilities of the Form under the @ui library + * + * Recommended to use this instead of which is not implemented properly + * (lacks focus on tab, filter options when typing, select option using keyeboard, etc) + */ +const InputSelectReact: FC = props => { + const wrapRef = useRef() + + // this is the selected option, memoize it once found + const selected = useMemo(() => ( + find(props.options, { value: props.value }) + ), [props.options, props.value]) + + // we need to create a portal to append our menus so they are always visible + const menuPortalTarget = useMemo(() => { + const el = document.getElementById('input-select-menu-target-portal') ?? document.createElement('div') + el.id = 'input-select-menu-target-portal' + + if (!document.body.contains(el)) { + document.body.append(el) + } + + return el + }, []) + + // throw the proper event type to the form handler (needs name & form element on target) + function handleSelect(option: unknown): void { + props.onChange({ + target: { + form: findParentFrom(wrapRef.current as HTMLDivElement), + name: props.name, + value: (option as InputSelectOption).value, + }, + } as ChangeEvent) + } + + return ( + } + > + + + ) +} + +export default InputSelectReact diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/index.ts b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/index.ts new file mode 100644 index 000000000..59973f658 --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/index.ts @@ -0,0 +1 @@ +export { default as InputSelectReact } from './InputSelectReact' diff --git a/src/libs/ui/lib/components/form/form-groups/form-radio/index.tsx b/src/libs/ui/lib/components/form/form-groups/form-radio/index.tsx index b292df6a2..31676b292 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-radio/index.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-radio/index.tsx @@ -6,6 +6,7 @@ import styles from './FormRadio.module.scss' interface FormRadioProps extends FormInputModel { readonly onChange: (event: FocusEvent) => void + options?: ReadonlyArray } const FormRadio: React.FC = (props: FormRadioProps) => { diff --git a/src/libs/ui/lib/components/form/form-input.model.ts b/src/libs/ui/lib/components/form/form-input.model.ts index ff19c7e3e..9b94ecec8 100644 --- a/src/libs/ui/lib/components/form/form-input.model.ts +++ b/src/libs/ui/lib/components/form/form-input.model.ts @@ -3,6 +3,7 @@ import { ReactNode } from 'react' import { FormInputAutocompleteOption } from './form-groups' import { InputEvent } from './form-input.event' import { ValidatorFn } from './validator-functions' +import { InputSelectOption } from './form-groups/form-input' export interface FormRadioButtonOption { checked: boolean @@ -63,7 +64,7 @@ export interface FormInputModel { readonly label?: string readonly name: string readonly notTabbable?: boolean - options?: ReadonlyArray + options?: ReadonlyArray readonly placeholder?: string readonly readonly?: boolean readonly spellCheck?: boolean @@ -71,7 +72,7 @@ export interface FormInputModel { tooltip?: FormInputTooltipOptions touched?: boolean // eslint-disable-next-line max-len - readonly type: 'card-set'| 'checkbox' | 'password' | 'radio' | 'rating' | 'text' | 'textarea' | 'image-picker' | 'toggle' + readonly type: 'card-set'| 'checkbox' | 'password' | 'radio' | 'rating' | 'select' | 'text' | 'textarea' | 'image-picker' | 'toggle' readonly validators?: ReadonlyArray value?: InputValue } From 0fc9fa3e4a489baca7c317cbc86763f258a4c4bb Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Nov 2023 16:25:44 +0200 Subject: [PATCH 07/23] add modals & action menus for handling add & edit categories & skills --- .../accordion-item/AccordionItem.module.scss | 6 +- .../accordion-item/AccordionItem.tsx | 11 ++- .../accordion-menu/AccordionMenu.module.scss | 1 - .../accordion-menu/AccordionMenu.tsx | 55 ----------- .../accordion/accordion-menu/index.ts | 1 - .../actions-menu/ActionsMenu.module.scss | 36 ++++++++ .../components/actions-menu/ActionsMenu.tsx | 91 +++++++++++++++++++ .../components/actions-menu/index.ts | 1 + .../CategoriesAccordion.tsx | 51 ++++++----- .../category-modal/CategoryModal.tsx | 39 ++++---- .../category-modal/category-form.config.ts | 2 +- .../src/skills-manager/components/index.ts | 2 + .../MoreActionsMenu.module.scss | 19 ++++ .../more-actions-menu/MoreActionsMenu.tsx | 53 +++++++++++ .../components/more-actions-menu/index.ts | 1 + .../components/search-input/SearchInput.tsx | 4 + .../components/skill-modal/SkillModal.tsx | 72 +++++++++++++++ .../components/skill-modal/index.ts | 1 + .../skill-modal/skill-form.config.ts | 78 ++++++++++++++++ .../skills-list/SkillsList.module.scss | 6 +- .../src/skills-manager/config/constants.ts | 38 ++++++++ .../admin/src/skills-manager/config/index.ts | 1 + .../landing-page/LandingPage.module.scss | 9 ++ .../landing-page/LandingPage.tsx | 38 ++++++-- .../src/skills-manager/lib/skills.utils.ts | 30 +++--- .../services/skills-categories.service.ts | 7 +- .../skills-manager/services/skills.service.ts | 22 +++-- .../skills-manager/skills-manager.context.tsx | 56 +++++++++--- 28 files changed, 567 insertions(+), 164 deletions(-) delete mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss delete mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx delete mode 100644 src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.tsx create mode 100644 src/apps/admin/src/skills-manager/components/actions-menu/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx create mode 100644 src/apps/admin/src/skills-manager/components/more-actions-menu/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/skill-modal/SkillModal.tsx create mode 100644 src/apps/admin/src/skills-manager/components/skill-modal/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/skill-modal/skill-form.config.ts create mode 100644 src/apps/admin/src/skills-manager/config/constants.ts create mode 100644 src/apps/admin/src/skills-manager/config/index.ts diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss index 18486e7bf..156077f57 100644 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss @@ -9,8 +9,6 @@ .itemHeader { display: flex; align-items: center; - - cursor: pointer; } .icon { @@ -33,6 +31,10 @@ letter-spacing: 0.05px; } +.textLabel, .icon { + cursor: pointer; +} + .badge { display: block; font-family: $font-roboto; diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx index d32f20ac0..777b4a0a4 100644 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx +++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames' import { IconOutline } from '~/libs/ui' -import { AccordionMenu, AccordionMenuItem } from '../accordion-menu' +import { ActionsMenu, ActionsMenuItem } from '../../actions-menu' import styles from './AccordionItem.module.scss' @@ -13,7 +13,7 @@ export interface AccordionItemProps { open?: boolean toggle?: () => void children: JSX.Element[] | JSX.Element | (() => JSX.Element[] | JSX.Element) - menuActions: AccordionMenuItem[] + menuActions: ActionsMenuItem[] onMenuAction: (a: string) => void } @@ -42,10 +42,13 @@ const AccordionItem: FC = props => { )} {props.menuActions?.length > 0 && ( - + className={styles.menu} + > + + )} diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss deleted file mode 100644 index fced2c8eb..000000000 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.module.scss +++ /dev/null @@ -1 +0,0 @@ -@import '@libs/ui/styles/includes'; diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx deleted file mode 100644 index 77ae6a210..000000000 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/AccordionMenu.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { FC, ReactNode, useState } from 'react' - -import { IconOutline } from '~/libs/ui' - -import styles from './AccordionMenu.module.scss' - -export interface AccordionMenuItem { - label: string - action: string -} - -interface AccordionMenuProps { - items: AccordionMenuItem[] - onAction: (action: string) => void - children?: ReactNode -} - -const AccordionMenu: FC = props => { - const [isMenuVisible, setIsMenuVisible] = useState(false) - - function toggleMenu(): void { - setIsMenuVisible(isVisible => !isVisible) - } - - function renderDropdownMenu(): ReactNode { - return ( -
    - {props.items.map(item => ( -
  • - - {item.label} - -
  • - ))} -
- ) - } - - return ( -
-
- {props.children || ( - - )} -
- {isMenuVisible && renderDropdownMenu()} -
- ) -} - -export default AccordionMenu diff --git a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts b/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts deleted file mode 100644 index 8797c2c22..000000000 --- a/src/apps/admin/src/skills-manager/components/accordion/accordion-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as AccordionMenu, type AccordionMenuItem } from './AccordionMenu' diff --git a/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss new file mode 100644 index 000000000..d33b657da --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss @@ -0,0 +1,36 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + position: relative; +} + +.trigger { + cursor: pointer; + + padding: $sp-1; + margin: -$sp-1; +} + +.menu { + padding: $sp-4 $sp-2; + border-radius: $sp-2; + box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.16), 0px 0px 2px 0px rgba(0, 0, 0, 0.48); + + min-width: 132px; + max-width: 320px; + + background: $tc-white; +} + +.menuItem { + padding: 0 $sp-2; + transition: 0.15s ease-in-out; + cursor: pointer; + &:hover { + background: $black-10; + } + + + .menuItem { + margin-top: $sp-2; + } +} diff --git a/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.tsx b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.tsx new file mode 100644 index 000000000..6990a9729 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.tsx @@ -0,0 +1,91 @@ +import { FC, MutableRefObject, ReactNode, useRef, useState } from 'react' +import { usePopper } from 'react-popper' +import classNames from 'classnames' + +import { Placement, PositioningStrategy } from '@popperjs/core' +import { useClickOutside } from '~/libs/shared' + +import styles from './ActionsMenu.module.scss' + +export interface ActionsMenuItem { + label: string + action: string +} + +interface ActionsMenuProps { + className?: string + items: ActionsMenuItem[] + onAction: (action: string) => void + children?: ReactNode + placement?: Placement + strategy?: PositioningStrategy +} + +const ActionsMenu: FC = props => { + const [menuIsVisible, setMenuIsVisible] = useState(false) + const triggerRef = useRef() + const popperRef = useRef() + + const popper = usePopper(triggerRef.current, popperRef.current, { + modifiers: [{ enabled: true, name: 'preventOverflow' }], + placement: props.placement ?? 'bottom-end', + strategy: props.strategy ?? 'fixed', + }) + + useClickOutside( + triggerRef.current as HTMLDivElement, + () => setMenuIsVisible(false), + menuIsVisible, + ) + + function toggleMenu(): void { + setMenuIsVisible(isVisible => !isVisible) + + if (!menuIsVisible && popper.update) { + setTimeout(popper.update) + } + } + + function renderDropdownMenu(): ReactNode { + return ( +
+ {menuIsVisible && ( +
    + {props.items.map(item => ( +
  • + + {item.label} + +
  • + ))} +
+ )} +
+ ) + } + + return ( +
+
} + tabIndex={-1} + > + {props.children} +
+ {renderDropdownMenu()} +
+ ) +} + +export default ActionsMenu diff --git a/src/apps/admin/src/skills-manager/components/actions-menu/index.ts b/src/apps/admin/src/skills-manager/components/actions-menu/index.ts new file mode 100644 index 000000000..fe0008d49 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/actions-menu/index.ts @@ -0,0 +1 @@ +export { default as ActionsMenu, type ActionsMenuItem } from './ActionsMenu' diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx index cab66a4b6..6ecd6aaf9 100644 --- a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx @@ -1,51 +1,54 @@ import { FC } from 'react' -import { CategoryGroup } from '../../lib' import { Accordion, AccordionItem } from '../accordion' import { SkillsList } from '../skills-list' -import { AccordionMenuItem } from '../accordion/accordion-menu' import { SkillsManagerContextValue, useSkillsManagerContext } from '../../skills-manager.context' +import { StandardizedSkillCategory } from '../../services' +import { CATEGORY_ITEM_ACTIONS, MENU_ACTIONS } from '../../config' interface CategoriesAccordionProps { - categories: CategoryGroup[] defaultOpen?: boolean } -const groupActions: AccordionMenuItem[] = [ - { action: 'edit:category', label: 'Edit Category' }, - { action: 'edit:skills:bulk', label: 'Bulk Edit Skills' }, -] - const CategoriesAccordion: FC = props => { const { + skillsFilter, setEditCategory, + categories, + groupedSkills, }: SkillsManagerContextValue = useSkillsManagerContext() - function handleMenuActions(action: string, category: CategoryGroup): void { + function handleMenuActions(action: string, category: StandardizedSkillCategory): void { switch (action) { - case 'edit:category': + case MENU_ACTIONS.editCategory.action: setEditCategory(category) break default: break } } + function renderCategoryAccordion(category: StandardizedSkillCategory): JSX.Element { + const categorySkills = groupedSkills[category.id] ?? [] + + return (!skillsFilter || categorySkills.length > 0) ? ( + + {() => ( + + )} + + ) : <> + } + return ( - {props.categories.map(category => ( - - {() => ( - - )} - - ))} + {categories.map(renderCategoryAccordion)} ) } diff --git a/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx index 27e7c9d63..f8e3224bf 100644 --- a/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx +++ b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx @@ -1,15 +1,15 @@ -import { ChangeEvent, FC, MutableRefObject, ReactNode, useCallback, useMemo, useRef, useState } from 'react' +import { FC, useCallback, useMemo, useState } from 'react' -import { BaseModal, Button, Form, FormInputModel, FormValue, InputText, InputTextarea, LoadingSpinner, formGetInputModel } from '~/libs/ui' +import { BaseModal, Form, formGetInputModel, FormInputModel, FormValue, LoadingSpinner } from '~/libs/ui' -import { StandardizedSkillCategory, saveStandardizedSkillCategory } from '../../services' +import { saveStandardizedSkillCategory, StandardizedSkillCategory } from '../../services' import { categoryFormDef, CategoryFormField } from './category-form.config' -import styles from './CategoryModal.module.scss' interface CategoryModalProps { category: StandardizedSkillCategory onClose: () => void + onSave: () => void } const CategoryModal: FC = props => { @@ -27,12 +27,15 @@ const CategoryModal: FC = props => { setLoading(true) return saveStandardizedSkillCategory(request as unknown as StandardizedSkillCategory) - .then((createdBadge: StandardizedSkillCategory) => { - console.log('saved', createdBadge) - + .then(() => { + props.onSave.call(undefined) props.onClose.call(undefined) }) - }, [props.onClose]) + .catch((e: any) => { + setLoading(false) + return Promise.reject(e) + }) + }, [props.onClose, props.onSave]) const formDef = useMemo(() => ({ ...categoryFormDef, @@ -44,17 +47,6 @@ const CategoryModal: FC = props => { }, }), [props.onClose]) - function renderForm(): ReactNode { - return ( - - ) - } - return ( = props => { size='lg' title={`${action} Category`} > - {renderForm()} + ) diff --git a/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts b/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts index daae8c65c..8b6449a35 100644 --- a/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts +++ b/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts @@ -28,7 +28,7 @@ export const categoryFormDef: FormDefinition = { { inputs: [ { - label: 'Name', + label: 'Category Name', name: CategoryFormField.name, placeholder: 'Enter category name', type: 'text', diff --git a/src/apps/admin/src/skills-manager/components/index.ts b/src/apps/admin/src/skills-manager/components/index.ts index cb75eadd8..a027b711e 100644 --- a/src/apps/admin/src/skills-manager/components/index.ts +++ b/src/apps/admin/src/skills-manager/components/index.ts @@ -1,5 +1,7 @@ export * from './accordion' export * from './categories-accordion' export * from './category-modal' +export * from './more-actions-menu' export * from './page-header' export * from './search-input' +export * from './skill-modal' diff --git a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss new file mode 100644 index 000000000..f3cac09ed --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss @@ -0,0 +1,19 @@ +@import '@libs/ui/styles/includes'; + +.stickyContainer { + position: absolute; + top: 0; + right: 0; + height: 100%; + &::before { + content: ''; + display: block; + height: calc(100% - 48px); + } +} + +.floatingActionBtn { + position: sticky; + right: 0; + bottom: $sp-4; +} diff --git a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx new file mode 100644 index 000000000..5afc07c12 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react' + +import { Button, IconOutline } from '~/libs/ui' + +import { GLOBAL_ACTIONS, MENU_ACTIONS } from '../../config' +import { ActionsMenu } from '../actions-menu' +import { StandardizedSkill, StandardizedSkillCategory } from '../../services' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../skills-manager.context' + +import styles from './MoreActionsMenu.module.scss' + +const MoreActionsMenu: FC<{}> = () => { + const { + setEditCategory, + setEditSkill, + }: SkillsManagerContextValue = useSkillsManagerContext() + + function handleMenuActions(action: string): void { + switch (action) { + case MENU_ACTIONS.addCategory.action: + setEditCategory({} as StandardizedSkillCategory) + break + case MENU_ACTIONS.addSkill.action: + setEditSkill({} as StandardizedSkill) + break + default: break + } + } + + return ( +
+
+ + + +
+
+ ) +} + +export default MoreActionsMenu diff --git a/src/apps/admin/src/skills-manager/components/more-actions-menu/index.ts b/src/apps/admin/src/skills-manager/components/more-actions-menu/index.ts new file mode 100644 index 000000000..36bd2706d --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/index.ts @@ -0,0 +1 @@ +export { default as MoreActionsMenu } from './MoreActionsMenu' diff --git a/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx index 6c895ef9d..305fda8f7 100644 --- a/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx +++ b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx @@ -15,6 +15,10 @@ const SearchInput: FC = props => { const [value, setValue] = useState(props.value) const debouncedOnChange = useMemo(() => debounce((newValue): void => { + if (newValue && newValue.length === 1) { + return + } + props.onChange.call(undefined, newValue ?? '') }, 300), [props.onChange]) diff --git a/src/apps/admin/src/skills-manager/components/skill-modal/SkillModal.tsx b/src/apps/admin/src/skills-manager/components/skill-modal/SkillModal.tsx new file mode 100644 index 000000000..160f61d32 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skill-modal/SkillModal.tsx @@ -0,0 +1,72 @@ +import { FC, ReactNode, useCallback, useMemo, useState } from 'react' + +import { BaseModal, Form, formGetInputModel, FormInputModel, FormValue, LoadingSpinner } from '~/libs/ui' + +import { saveStandardizedSkill, StandardizedSkill, StandardizedSkillCategory } from '../../services' + +import { skillFormDef, SkillFormField } from './skill-form.config' + +interface SkillModalProps { + skill: StandardizedSkill + categories: StandardizedSkillCategory[] + onClose: () => void + onSave: () => void +} + +const SkillModal: FC = props => { + const action = props.skill?.id ? 'edit' : 'add' + + const [loading, setLoading] = useState(false) + + const generateRequest = useCallback((inputs: ReadonlyArray): FormValue => ({ + categoryId: formGetInputModel(inputs, SkillFormField.category).value as string, + description: formGetInputModel(inputs, SkillFormField.description).value as string, + id: props.skill.id as string, + name: formGetInputModel(inputs, SkillFormField.name).value as string, + }), []) + + const saveAsync = useCallback(async (request: FormValue): Promise => { + setLoading(true) + + return saveStandardizedSkill(request as unknown as StandardizedSkill) + .then(() => { + props.onSave.call(undefined) + props.onClose.call(undefined) + }) + .catch((e: any) => { + setLoading(false) + return Promise.reject(e) + }) + }, [props.onClose, props.onSave]) + + const formDef = useMemo(() => skillFormDef( + props.onClose, + props.categories, + ), [props.categories, props.onClose]) + + function renderForm(): ReactNode { + return ( + + ) + } + + return ( + + {renderForm()} + + + ) +} + +export default SkillModal diff --git a/src/apps/admin/src/skills-manager/components/skill-modal/index.ts b/src/apps/admin/src/skills-manager/components/skill-modal/index.ts new file mode 100644 index 000000000..b263196e6 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skill-modal/index.ts @@ -0,0 +1 @@ +export { default as SkillModal } from './SkillModal' diff --git a/src/apps/admin/src/skills-manager/components/skill-modal/skill-form.config.ts b/src/apps/admin/src/skills-manager/components/skill-modal/skill-form.config.ts new file mode 100644 index 000000000..e6fba9a8c --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/skill-modal/skill-form.config.ts @@ -0,0 +1,78 @@ +import { FormDefinition, InputSelectOption, validatorRequired } from '~/libs/ui' + +import { StandardizedSkillCategory } from '../../services' + +export enum SkillFormField { + category = 'categoryId', + description = 'description', + name = 'name', +} + +const mapCategoryToSelectOption = (categories: StandardizedSkillCategory[]): InputSelectOption[] => ( + categories.map(c => ({ label: c.name, value: c.id })) +) + +export const skillFormDef = ( + onCancelClick: () => void, + categories?: StandardizedSkillCategory[], +): FormDefinition => ({ + buttons: { + primaryGroup: [ + { + buttonStyle: 'secondary', + isSubmit: false, + label: 'Cancel', + onClick: onCancelClick, + size: 'lg', + type: 'button', + }, + { + buttonStyle: 'primary', + isSubmit: true, + label: 'Save', + size: 'lg', + type: 'submit', + }, + ], + }, + groups: [ + { + inputs: [ + { + label: 'Skill Name', + name: SkillFormField.name, + placeholder: 'Enter skill name', + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Description', + name: SkillFormField.description, + type: 'textarea', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Skill Category', + name: SkillFormField.category, + options: mapCategoryToSelectOption(categories ?? []), + placeholder: 'Select category', + type: 'select', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + ], + }, + ], + successMessage: false, +} as FormDefinition) diff --git a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss index fe112dce7..5bc7b26d6 100644 --- a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss +++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss @@ -10,9 +10,5 @@ } .skillItem { - padding: $sp-1 $sp-2; - - + .skillItem { - margin-top: $sp-2; - } + padding: $sp-2 $sp-2; } diff --git a/src/apps/admin/src/skills-manager/config/constants.ts b/src/apps/admin/src/skills-manager/config/constants.ts new file mode 100644 index 000000000..1318c295a --- /dev/null +++ b/src/apps/admin/src/skills-manager/config/constants.ts @@ -0,0 +1,38 @@ +import { type ActionsMenuItem } from '../components/actions-menu' + +export const ADD_SKILL_ACTION: ActionsMenuItem = { + action: 'add:skill', + label: 'Add Skill', +} + +export const ADD_CATEGORY_ACTION: ActionsMenuItem = { + action: 'add:category', + label: 'Add Category', +} + +export const EDIT_CATEGORY_ACTION: ActionsMenuItem = { + action: 'edit:category', + label: 'Edit Category', +} + +export const BULK_EDIT_SKILLS_ACTION: ActionsMenuItem = { + action: 'bulk-edit:skill', + label: 'Bulk Edit Skills', +} + +export const GLOBAL_ACTIONS: ActionsMenuItem[] = [ + ADD_SKILL_ACTION, + ADD_CATEGORY_ACTION, +] + +export const MENU_ACTIONS = { + addCategory: ADD_CATEGORY_ACTION, + addSkill: ADD_SKILL_ACTION, + bulkEditSkills: BULK_EDIT_SKILLS_ACTION, + editCategory: EDIT_CATEGORY_ACTION, +} + +export const CATEGORY_ITEM_ACTIONS: ActionsMenuItem[] = [ + EDIT_CATEGORY_ACTION, + // BULK_EDIT_SKILLS_ACTION, +] diff --git a/src/apps/admin/src/skills-manager/config/index.ts b/src/apps/admin/src/skills-manager/config/index.ts new file mode 100644 index 000000000..f87cf0102 --- /dev/null +++ b/src/apps/admin/src/skills-manager/config/index.ts @@ -0,0 +1 @@ +export * from './constants' diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss b/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss index e69de29bb..54a747e2e 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss @@ -0,0 +1,9 @@ +@import '@libs/ui/styles/includes'; + +.contentWrap { + position: relative; +} + +.contentAccordion { + margin-top: $sp-3; +} diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx index 3c52532f6..af5fef2e6 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -2,34 +2,58 @@ import { FC } from 'react' import { ContentLayout, PageTitle } from '~/libs/ui' -import { CategoriesAccordion, CategoryModal, PageHeader, SearchInput } from '../components' import { SkillsManagerContextValue, useSkillsManagerContext } from '../skills-manager.context' +import { + CategoriesAccordion, + CategoryModal, + MoreActionsMenu, + PageHeader, + SearchInput, + SkillModal, +} from '../components' + +import styles from './LandingPage.module.scss' const LandingPage: FC<{}> = () => { const { - groupedSkills, + categories, setSkillsFilter, skillsFilter, editCategory, + editSkill, setEditCategory, + setEditSkill, + refetchCategories, + refetchSkills, }: SkillsManagerContextValue = useSkillsManagerContext() return ( - + Skills Manager | Admin - +
+ +
+ + {!!editCategory && ( + )} + + {!!editSkill && ( + )}
diff --git a/src/apps/admin/src/skills-manager/lib/skills.utils.ts b/src/apps/admin/src/skills-manager/lib/skills.utils.ts index 45b506901..3ca5d6e07 100644 --- a/src/apps/admin/src/skills-manager/lib/skills.utils.ts +++ b/src/apps/admin/src/skills-manager/lib/skills.utils.ts @@ -1,33 +1,25 @@ -import { escapeRegExp, orderBy } from 'lodash' +import { escapeRegExp, mapValues, orderBy } from 'lodash' -import { StandardizedSkill, StandardizedSkillCategory } from '../services' +import { StandardizedSkill } from '../services' -export interface CategoryGroup extends StandardizedSkillCategory { - skills: StandardizedSkill[] +export interface GroupedSkills { + [id: string]: StandardizedSkill[] } -export const groupSkillsByCategories = (skills: StandardizedSkill[]): CategoryGroup[] => { - const allGroups = skills.reduce((grouped, skill) => { +export const groupSkillsByCategory = (skills: StandardizedSkill[]): GroupedSkills => { + const groupedSkills = skills.reduce((grouped, skill) => { if (!grouped[skill.category.id]) { - grouped[skill.category.id] = { - ...skill.category, - skills: [], - } + grouped[skill.category.id] = [] } - grouped[skill.category.id].skills.push(skill) + grouped[skill.category.id].push(skill) return grouped - }, {} as {[key: string]: CategoryGroup}) + }, {} as GroupedSkills) - // sort groups, then sort skills, all by name ascending order - const sortedGroups = orderBy(Object.values(allGroups), 'name', 'asc') - const allSorted = sortedGroups.map(group => ({ - ...group, - skills: orderBy(group.skills, 'name', 'asc'), - })) + const sortedGroupedSkills = mapValues(groupedSkills, s => orderBy(s, 'name', 'asc')) - return allSorted + return sortedGroupedSkills } export const findSkillsMatches = (skills: StandardizedSkill[], skillsFilter: string): StandardizedSkill[] => { diff --git a/src/apps/admin/src/skills-manager/services/skills-categories.service.ts b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts index 9fc322cd7..56ea39398 100644 --- a/src/apps/admin/src/skills-manager/services/skills-categories.service.ts +++ b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts @@ -8,16 +8,15 @@ const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills/categories` export interface StandardizedSkillCategory extends UserSkillCategory {} -export const useFetchCategories = (): StandardizedSkillCategory[] | undefined => { +export const useFetchCategories = (): SWRResponse => { const url = `${baseUrl}?perPage=9999` - const { data: allCategories }: SWRResponse - = useSWR(url, xhrGetAsync, { + const response = useSWR(url, xhrGetAsync, { refreshInterval: 0, revalidateOnFocus: false, }) - return allCategories ?? [] + return response } export const saveStandardizedSkillCategory = (category: StandardizedSkillCategory) diff --git a/src/apps/admin/src/skills-manager/services/skills.service.ts b/src/apps/admin/src/skills-manager/services/skills.service.ts index b15cd60dd..9e620f4c8 100644 --- a/src/apps/admin/src/skills-manager/services/skills.service.ts +++ b/src/apps/admin/src/skills-manager/services/skills.service.ts @@ -1,20 +1,28 @@ +import { omit } from 'lodash' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { UserSkill, xhrGetAsync } from '~/libs/core' +import { UserSkill, xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' -const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` +const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills/skills` export interface StandardizedSkill extends UserSkill {} -export const useFetchSkills = (): StandardizedSkill[] | undefined => { - const url = `${baseUrl}/skills?perPage=9999` +export const useFetchSkills = (): SWRResponse => { + const url = `${baseUrl}?perPage=9999` - const { data: allSkills }: SWRResponse - = useSWR(url, xhrGetAsync, { + const response = useSWR(url, xhrGetAsync, { refreshInterval: 0, revalidateOnFocus: false, }) - return allSkills + return response +} + +export const saveStandardizedSkill = (category: StandardizedSkill) +: Promise => { + const xhrSaveAsyncFn = category.id ? xhrPutAsync : xhrPostAsync + const url = `${baseUrl}${category.id ? `/${category.id}` : ''}` + + return xhrSaveAsyncFn(url, omit(category, 'id')) } diff --git a/src/apps/admin/src/skills-manager/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/skills-manager.context.tsx index 318e760e9..2963c2ad2 100644 --- a/src/apps/admin/src/skills-manager/skills-manager.context.tsx +++ b/src/apps/admin/src/skills-manager/skills-manager.context.tsx @@ -1,24 +1,33 @@ import { createContext, FC, ReactNode, useContext, useMemo, useState } from 'react' import { noop, orderBy } from 'lodash' +import { SWRResponse } from 'swr' import { StandardizedSkill, StandardizedSkillCategory, useFetchCategories, useFetchSkills } from './services' -import { CategoryGroup, findSkillsMatches, groupSkillsByCategories } from './lib' +import { findSkillsMatches, GroupedSkills, groupSkillsByCategory } from './lib' export interface SkillsManagerContextValue { - allCategories: StandardizedSkillCategory[] + categories: StandardizedSkillCategory[] skillsFilter: string setSkillsFilter: (filter: string) => void skillsList: StandardizedSkill[] - groupedSkills: CategoryGroup[] - editCategory: CategoryGroup | undefined - setEditCategory: (group?: CategoryGroup) => void + groupedSkills: GroupedSkills + editCategory: StandardizedSkillCategory | undefined + setEditCategory: (group?: StandardizedSkillCategory) => void + editSkill: StandardizedSkill | undefined + setEditSkill: (skill?: StandardizedSkill) => void + refetchCategories: () => void + refetchSkills: () => void } const SkillsManagerRC = createContext({ - allCategories: [], + categories: [], editCategory: undefined, - groupedSkills: [], + editSkill: undefined, + groupedSkills: {}, + refetchCategories: noop, + refetchSkills: noop, setEditCategory: noop, + setEditSkill: noop, setSkillsFilter: noop, skillsFilter: '', skillsList: [], @@ -30,24 +39,43 @@ interface SkillsManagerContextProps { export const SkillsManagerContext: FC = props => { const [skillsFilter, setSkillsFilter] = useState('') - const [editCategory, setEditCategory] = useState() + const [editCategory, setEditCategory] = useState() + const [editSkill, setEditSkill] = useState() - const allSkills = useFetchSkills() - const allCategories = useFetchCategories() + const { + data: allSkills, + mutate: refetchSkills, + }: SWRResponse = useFetchSkills() + const { + data: allCategories, + mutate: refetchCategories, + }: SWRResponse = useFetchCategories() const skills = useMemo(() => findSkillsMatches(allSkills ?? [], skillsFilter), [allSkills, skillsFilter]) - - const groupedSkills = useMemo(() => groupSkillsByCategories(skills), [skills]) + const groupedSkills = useMemo(() => groupSkillsByCategory(skills), [skills]) const contextValue = useMemo(() => ({ - allCategories: orderBy(allCategories ?? [], 'name', 'asc'), + categories: orderBy(allCategories ?? [], 'name', 'asc'), editCategory, + editSkill, groupedSkills, + refetchCategories, + refetchSkills, setEditCategory, + setEditSkill, setSkillsFilter, skillsFilter, skillsList: skills, - }), [allCategories, editCategory, groupedSkills, skills, skillsFilter]) + }), [ + allCategories, + editCategory, + editSkill, + groupedSkills, + refetchCategories, + refetchSkills, + skills, + skillsFilter, + ]) return ( Date: Tue, 28 Nov 2023 09:45:14 +0200 Subject: [PATCH 08/23] TSJR-314 - work for bulk edit --- .../src/skills-manager/SkillsManager.tsx | 2 +- .../actions-menu/ActionsMenu.module.scss | 4 ++ .../CategoriesAccordion.tsx | 25 +++++-- .../MoreActionsMenu.module.scss | 12 ++++ .../more-actions-menu/MoreActionsMenu.tsx | 5 +- .../skills-list/SkillsList.module.scss | 7 ++ .../components/skills-list/SkillsList.tsx | 49 ++++++++++---- .../src/skills-manager/config/constants.ts | 2 +- .../admin/src/skills-manager/context/index.ts | 1 + .../{ => context}/skills-manager.context.tsx | 28 ++++---- .../context/use-skills-bulk-editor.tsx | 67 +++++++++++++++++++ .../context/use-skills.context.tsx | 14 ++++ .../landing-page/LandingPage.tsx | 2 +- .../lib/utils/generic-data-object.type.ts | 2 +- 14 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 src/apps/admin/src/skills-manager/context/index.ts rename src/apps/admin/src/skills-manager/{ => context}/skills-manager.context.tsx (85%) create mode 100644 src/apps/admin/src/skills-manager/context/use-skills-bulk-editor.tsx create mode 100644 src/apps/admin/src/skills-manager/context/use-skills.context.tsx diff --git a/src/apps/admin/src/skills-manager/SkillsManager.tsx b/src/apps/admin/src/skills-manager/SkillsManager.tsx index a895c6392..b194d0edc 100644 --- a/src/apps/admin/src/skills-manager/SkillsManager.tsx +++ b/src/apps/admin/src/skills-manager/SkillsManager.tsx @@ -4,7 +4,7 @@ import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { skillsManagerRoutes } from './skills-manager.routes' -import { SkillsManagerContext } from './skills-manager.context' +import { SkillsManagerContext } from './context' const SkillsManager: FC<{}> = () => { const { getRouteElement }: RouterContextData = useContext(routerContext) diff --git a/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss index d33b657da..e8ad6ecff 100644 --- a/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss +++ b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss @@ -9,6 +9,10 @@ padding: $sp-1; margin: -$sp-1; + + + * { + z-index: 9; + } } .menu { diff --git a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx index 6ecd6aaf9..a7d58f058 100644 --- a/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx +++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx @@ -2,7 +2,7 @@ import { FC } from 'react' import { Accordion, AccordionItem } from '../accordion' import { SkillsList } from '../skills-list' -import { SkillsManagerContextValue, useSkillsManagerContext } from '../../skills-manager.context' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../context' import { StandardizedSkillCategory } from '../../services' import { CATEGORY_ITEM_ACTIONS, MENU_ACTIONS } from '../../config' @@ -16,6 +16,10 @@ const CategoriesAccordion: FC = props => { setEditCategory, categories, groupedSkills, + isBulkEditing, + toggleEditMode, + toggleEditSkill, + isSkillSelected, }: SkillsManagerContextValue = useSkillsManagerContext() function handleMenuActions(action: string, category: StandardizedSkillCategory): void { @@ -23,6 +27,9 @@ const CategoriesAccordion: FC = props => { case MENU_ACTIONS.editCategory.action: setEditCategory(category) break + case MENU_ACTIONS.bulkEditSkills.action: + toggleEditMode(category) + break default: break } } @@ -35,12 +42,18 @@ const CategoriesAccordion: FC = props => { key={category.id} label={category.name} badgeCount={categorySkills.length} - open={props.defaultOpen} + open={props.defaultOpen || !!isBulkEditing} menuActions={CATEGORY_ITEM_ACTIONS} onMenuAction={function handle(action: string) { handleMenuActions(action, category) }} > {() => ( - + )} ) : <> @@ -48,7 +61,11 @@ const CategoriesAccordion: FC = props => { return ( - {categories.map(renderCategoryAccordion)} + {isBulkEditing ? ( + renderCategoryAccordion(isBulkEditing) + ) : ( + categories.map(renderCategoryAccordion) + )} ) } diff --git a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss index f3cac09ed..fc5b06628 100644 --- a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss +++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss @@ -5,6 +5,7 @@ top: 0; right: 0; height: 100%; + width: 0; &::before { content: ''; display: block; @@ -17,3 +18,14 @@ right: 0; bottom: $sp-4; } + +.triggerBtn.triggerBtn { + padding-left: $sp-4; + padding-right: $sp-4; +} + +.menu { + position: absolute; + right: 0; + bottom: 0; +} diff --git a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx index 5afc07c12..17edc59a2 100644 --- a/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx +++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.tsx @@ -5,7 +5,7 @@ import { Button, IconOutline } from '~/libs/ui' import { GLOBAL_ACTIONS, MENU_ACTIONS } from '../../config' import { ActionsMenu } from '../actions-menu' import { StandardizedSkill, StandardizedSkillCategory } from '../../services' -import { SkillsManagerContextValue, useSkillsManagerContext } from '../../skills-manager.context' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../context' import styles from './MoreActionsMenu.module.scss' @@ -39,8 +39,9 @@ const MoreActionsMenu: FC<{}> = () => { > diff --git a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss index 5bc7b26d6..9eb0a215e 100644 --- a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss +++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss @@ -11,4 +11,11 @@ .skillItem { padding: $sp-2 $sp-2; + display: flex; + align-items: center; + gap: $sp-2; + + .isEditMode & { + cursor: pointer; + } } diff --git a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx index e3b7dbc22..dbeed588b 100644 --- a/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx +++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx @@ -1,4 +1,6 @@ import { FC } from 'react' +import CheckBox from 'rc-checkbox' +import classNames from 'classnames' import { StandardizedSkill } from '../../services' @@ -6,20 +8,41 @@ import styles from './SkillsList.module.scss' interface SkillsListProps { skills: StandardizedSkill[] + editMode?: boolean + onSelect: (skill: StandardizedSkill) => void + isSelected: (skill: StandardizedSkill) => boolean } -const SkillsList: FC = props => ( -
-
    - {props.skills.map(skill => ( -
  • -
    - {skill.name} -
    -
  • - ))} -
-
-) +const SkillsList: FC = props => { + function handleToggle(skill: StandardizedSkill): void { + if (!props.editMode) { + return + } + + props.onSelect(skill) + } + + return ( +
+
    + {props.skills.map(skill => ( +
  • + {props.editMode && ( +
    + +
    + )} +
    + {skill.name} +
    +
  • + ))} +
+
+ ) +} export default SkillsList diff --git a/src/apps/admin/src/skills-manager/config/constants.ts b/src/apps/admin/src/skills-manager/config/constants.ts index 1318c295a..7bbbfabd3 100644 --- a/src/apps/admin/src/skills-manager/config/constants.ts +++ b/src/apps/admin/src/skills-manager/config/constants.ts @@ -34,5 +34,5 @@ export const MENU_ACTIONS = { export const CATEGORY_ITEM_ACTIONS: ActionsMenuItem[] = [ EDIT_CATEGORY_ACTION, - // BULK_EDIT_SKILLS_ACTION, + BULK_EDIT_SKILLS_ACTION, ] diff --git a/src/apps/admin/src/skills-manager/context/index.ts b/src/apps/admin/src/skills-manager/context/index.ts new file mode 100644 index 000000000..5ada61b64 --- /dev/null +++ b/src/apps/admin/src/skills-manager/context/index.ts @@ -0,0 +1 @@ +export * from './skills-manager.context' diff --git a/src/apps/admin/src/skills-manager/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/context/skills-manager.context.tsx similarity index 85% rename from src/apps/admin/src/skills-manager/skills-manager.context.tsx rename to src/apps/admin/src/skills-manager/context/skills-manager.context.tsx index 2963c2ad2..ec85df92e 100644 --- a/src/apps/admin/src/skills-manager/skills-manager.context.tsx +++ b/src/apps/admin/src/skills-manager/context/skills-manager.context.tsx @@ -1,11 +1,13 @@ import { createContext, FC, ReactNode, useContext, useMemo, useState } from 'react' -import { noop, orderBy } from 'lodash' +import { orderBy } from 'lodash' import { SWRResponse } from 'swr' -import { StandardizedSkill, StandardizedSkillCategory, useFetchCategories, useFetchSkills } from './services' -import { findSkillsMatches, GroupedSkills, groupSkillsByCategory } from './lib' +import { StandardizedSkill, StandardizedSkillCategory, useFetchCategories, useFetchSkills } from '../services' +import { findSkillsMatches, GroupedSkills, groupSkillsByCategory } from '../lib' -export interface SkillsManagerContextValue { +import { SkillsBulkEditorValue, useSkillsBulkEditor } from './use-skills-bulk-editor' + +export interface SkillsManagerContextValue extends SkillsBulkEditorValue { categories: StandardizedSkillCategory[] skillsFilter: string setSkillsFilter: (filter: string) => void @@ -20,18 +22,7 @@ export interface SkillsManagerContextValue { } const SkillsManagerRC = createContext({ - categories: [], - editCategory: undefined, - editSkill: undefined, - groupedSkills: {}, - refetchCategories: noop, - refetchSkills: noop, - setEditCategory: noop, - setEditSkill: noop, - setSkillsFilter: noop, - skillsFilter: '', - skillsList: [], -}) +} as SkillsManagerContextValue) interface SkillsManagerContextProps { children?: ReactNode @@ -46,6 +37,7 @@ export const SkillsManagerContext: FC = props => { data: allSkills, mutate: refetchSkills, }: SWRResponse = useFetchSkills() + const { data: allCategories, mutate: refetchCategories, @@ -54,7 +46,10 @@ export const SkillsManagerContext: FC = props => { const skills = useMemo(() => findSkillsMatches(allSkills ?? [], skillsFilter), [allSkills, skillsFilter]) const groupedSkills = useMemo(() => groupSkillsByCategory(skills), [skills]) + const skillsBulkEditor = useSkillsBulkEditor(skills) + const contextValue = useMemo(() => ({ + ...skillsBulkEditor, categories: orderBy(allCategories ?? [], 'name', 'asc'), editCategory, editSkill, @@ -67,6 +62,7 @@ export const SkillsManagerContext: FC = props => { skillsFilter, skillsList: skills, }), [ + skillsBulkEditor, allCategories, editCategory, editSkill, diff --git a/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor.tsx b/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor.tsx new file mode 100644 index 000000000..e371f7d21 --- /dev/null +++ b/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { isEmpty } from 'lodash' + +import { GenericDataObject } from '~/libs/shared' + +import { StandardizedSkill, StandardizedSkillCategory } from '../services' + +type SelectedSkillsKeyMap = GenericDataObject + +export interface SkillsBulkEditorValue { + isBulkEditing: StandardizedSkillCategory | undefined + isSkillSelected: (skill: StandardizedSkill) => boolean + selectAll: () => void + toggleAll: () => void + toggleEditMode: (category: StandardizedSkillCategory) => void + toggleEditSkill: (skill: StandardizedSkill) => void +} + +export const useSkillsBulkEditor = (skills: StandardizedSkill[]): SkillsBulkEditorValue => { + const [isBulkEditing, setIsBulkEditing] = useState() + const [selectedSkills, setSelectedSkills] = useState({} as SelectedSkillsKeyMap) + + function toggleEditMode(category?: StandardizedSkillCategory): void { + setIsBulkEditing(d => (d?.id === category?.id ? undefined : category)) + } + + function isSkillSelected(skill: StandardizedSkill): boolean { + return !!selectedSkills[skill.id] + } + + function toggleEditSkill(skill: StandardizedSkill): void { + setSelectedSkills(prevSkills => ({ + ...prevSkills, + [skill.id]: prevSkills[skill.id] ? undefined : skill, + })) + } + + function selectAll(): void { + const skillsById = skills.reduce((all, skill) => { + all[skill.id] = skill + return all + }, {} as SelectedSkillsKeyMap) + setSelectedSkills(skillsById) + } + + function toggleAll(): void { + if (isEmpty(selectedSkills)) { + selectAll() + return + } + + setSelectedSkills({} as SelectedSkillsKeyMap) + } + + useEffect(() => { + setSelectedSkills({} as SelectedSkillsKeyMap) + }, [isBulkEditing]) + + return { + isBulkEditing, + isSkillSelected, + selectAll, + toggleAll, + toggleEditMode, + toggleEditSkill, + } +} diff --git a/src/apps/admin/src/skills-manager/context/use-skills.context.tsx b/src/apps/admin/src/skills-manager/context/use-skills.context.tsx new file mode 100644 index 000000000..4b413c068 --- /dev/null +++ b/src/apps/admin/src/skills-manager/context/use-skills.context.tsx @@ -0,0 +1,14 @@ +import { SWRResponse } from 'swr' + +import { StandardizedSkill, useFetchSkills } from '../services' + +interface SkillsContextValue { + +} + +export const useSkillsContext = (): void => { + const { + data: allSkills, + mutate: refetchSkills, + }: SWRResponse = useFetchSkills() +} diff --git a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx index af5fef2e6..2cd1c2ff9 100644 --- a/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx +++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx @@ -2,7 +2,7 @@ import { FC } from 'react' import { ContentLayout, PageTitle } from '~/libs/ui' -import { SkillsManagerContextValue, useSkillsManagerContext } from '../skills-manager.context' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../context' import { CategoriesAccordion, CategoryModal, diff --git a/src/libs/shared/lib/utils/generic-data-object.type.ts b/src/libs/shared/lib/utils/generic-data-object.type.ts index c25d4fd02..41f3892d8 100644 --- a/src/libs/shared/lib/utils/generic-data-object.type.ts +++ b/src/libs/shared/lib/utils/generic-data-object.type.ts @@ -1 +1 @@ -export type GenericDataObject = { [key: string]: string | GenericDataObject } +export type GenericDataObject = { [key: string]: GenericDataObject | T } From be2ebd04ef8955eeda49ca38d99b591636c984b8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 28 Nov 2023 15:00:43 +0200 Subject: [PATCH 09/23] TSJR-314 - bulk archive skills --- .../bulk-editor/BulkEditor.module.scss | 7 ++ .../components/bulk-editor/BulkEditor.tsx | 64 ++++++++++++++ .../ArchiveSkillsModal.module.scss | 7 ++ .../ArchiveSkillsModal.tsx | 45 ++++++++++ .../bulk-editor/archive-skills-modal/index.ts | 1 + .../components/bulk-editor/index.ts | 1 + .../CategoriesAccordion.tsx | 19 ++-- .../MoreActionsMenu.module.scss | 2 +- .../more-actions-menu/MoreActionsMenu.tsx | 2 +- .../page-header/PageHeader.module.scss | 5 ++ .../components/page-header/PageHeader.tsx | 4 +- .../components/skills-list/SkillsList.tsx | 10 ++- .../src/skills-manager/config/constants.ts | 8 +- .../context/skills-manager.context.tsx | 24 +++-- .../use-skills-bulk-editor-context.tsx | 77 ++++++++++++++++ .../context/use-skills-bulk-editor.tsx | 67 -------------- .../landing-page/LandingPage.module.scss | 4 + .../landing-page/LandingPage.tsx | 20 ++++- .../form/form-groups/form-input/index.ts | 1 + .../input-checkbox/InputCheckbox.module.scss | 88 +++++++++++++++++++ .../input-checkbox/InputCheckbox.tsx | 44 ++++++++++ .../form-input/input-checkbox/index.ts | 1 + 22 files changed, 406 insertions(+), 95 deletions(-) create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.tsx create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.module.scss create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.tsx create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/index.ts create mode 100644 src/apps/admin/src/skills-manager/components/bulk-editor/index.ts create mode 100644 src/apps/admin/src/skills-manager/context/use-skills-bulk-editor-context.tsx delete mode 100644 src/apps/admin/src/skills-manager/context/use-skills-bulk-editor.tsx create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.module.scss create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.tsx create mode 100644 src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/index.ts diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.module.scss b/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.module.scss new file mode 100644 index 000000000..0d7dcafde --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.module.scss @@ -0,0 +1,7 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + > * { + margin-left: $sp-4; + } +} diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.tsx b/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.tsx new file mode 100644 index 000000000..79ba60da8 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.tsx @@ -0,0 +1,64 @@ +import { FC, useState } from 'react' +import classNames from 'classnames' + +import { Button } from '~/libs/ui' + +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../context' + +import { ArchiveSkillsModal } from './archive-skills-modal' +import styles from './BulkEditor.module.scss' + +interface BulkEditorProps { + className?: string +} + +const BulkEditor: FC = props => { + const { bulkEditorCtx: context }: SkillsManagerContextValue = useSkillsManagerContext() + const [showArchive, setShowArchive] = useState(false) + + function toggleArchive(): void { + setShowArchive(d => !d) + } + + const hasSelection = context.selectedSkills.length > 0 + + return ( +
+
+ ) +} + +export default BulkEditor diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.module.scss b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.module.scss new file mode 100644 index 000000000..2ba50de8b --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.module.scss @@ -0,0 +1,7 @@ +@import '@libs/ui/styles/includes'; + +.modalBody { + .skillsList { + column-count: 2; + } +} diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.tsx b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.tsx new file mode 100644 index 000000000..ced0bcda2 --- /dev/null +++ b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react' + +import { BaseModal, Button } from '~/libs/ui' + +import { StandardizedSkill } from '../../../services' +import { SkillsList } from '../../skills-list' +import { SkillsManagerContextValue, useSkillsManagerContext } from '../../../context' + +import styles from './ArchiveSkillsModal.module.scss' + +interface ArchiveSkillsModalProps { + skills: StandardizedSkill[] + onClose: () => void +} + +const ArchiveSkillsModal: FC = props => { + const { bulkEditorCtx: context }: SkillsManagerContextValue = useSkillsManagerContext() + + return ( + +