diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3dc5fdf4d..77ef2c169 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -257,6 +257,7 @@ workflows:
branches:
only:
- dev
+ - TSJR-314_skill-manager_landing-page
- deployQa:
context: org-global
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/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..e275ec8fd
--- /dev/null
+++ b/src/apps/admin/src/admin.routes.tsx
@@ -0,0 +1,34 @@
+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 = (
+ 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: [
+ ...skillsManagerRoutes,
+ {
+ element: ,
+ id: 'Default Admin Route',
+ route: '',
+ },
+ ],
+ domain: AppSubdomain.admin,
+ element: ,
+ id: toolTitle,
+ rolesRequired: [UserRole.administrator],
+ route: rootRoute,
+ },
+]
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/skills-manager/SkillsManager.tsx b/src/apps/admin/src/skills-manager/SkillsManager.tsx
new file mode 100644
index 000000000..b194d0edc
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/SkillsManager.tsx
@@ -0,0 +1,22 @@
+import { FC, useContext } from 'react'
+import { Outlet, Routes } from 'react-router-dom'
+
+import { routerContext, RouterContextData } from '~/libs/core'
+
+import { skillsManagerRoutes } from './skills-manager.routes'
+import { SkillsManagerContext } from './context'
+
+const SkillsManager: FC<{}> = () => {
+ const { getRouteElement }: RouterContextData = useContext(routerContext)
+
+ return (
+
+
+
+ {skillsManagerRoutes.map(getRouteElement)}
+
+
+ )
+}
+
+export default SkillsManager
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..f4e4ecc47
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/accordion/Accordion.tsx
@@ -0,0 +1,82 @@
+import {
+ Children,
+ cloneElement,
+ FC,
+ isValidElement,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+
+import { AccordionItemProps } from './accordion-item'
+import styles from './Accordion.module.scss'
+
+interface AccordionProps {
+ children: JSX.Element[] | JSX.Element
+ 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] }))
+ }, [])
+
+ // check if props have changed and update the openedSections synchronously
+ if (prevProps.current.defaultOpen !== props.defaultOpen) {
+ prevProps.current = { ...props }
+ Object.assign(openedSections, computeOpenSectionsState(props))
+ }
+
+ // 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])
+
+ 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..34546ec06
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.module.scss
@@ -0,0 +1,64 @@
+@import '@libs/ui/styles/includes';
+
+.wrap {
+ + .wrap {
+ margin-top: $sp-8;
+ }
+}
+
+.itemHeader {
+ display: flex;
+ align-items: center;
+}
+
+.icon {
+ color: $turq-120;
+ transition: 0.2s ease-in-out;
+ margin-right: $sp-2;
+}
+
+.menuIcon {
+ color: $turq-120;
+}
+
+.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;
+}
+
+.textLabel, .icon {
+ cursor: pointer;
+}
+
+.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..b86af4876
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/accordion/accordion-item/AccordionItem.tsx
@@ -0,0 +1,60 @@
+import { FC, useMemo } from 'react'
+import classNames from 'classnames'
+
+import { IconOutline } from '~/libs/ui'
+
+import { ActionsMenu, ActionsMenuItem } from '../../actions-menu'
+
+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)
+ menuActions: ActionsMenuItem[]
+ onMenuAction: (a: string) => void
+}
+
+const AccordionItem: FC = props => {
+ const content = useMemo(() => (!props.open ? <>> : (
+
+ {typeof props.children === 'function' ? props.children.call(undefined) : props.children}
+
+ )), [props.children, props.open])
+
+ return (
+
+
+
+
+
+
+ {props.label && (
+
+ {props.label}
+
+ )}
+ {props.badgeCount !== undefined && (
+
+ {props.badgeCount}
+
+ )}
+ {props.menuActions?.length > 0 && (
+
+
+
+ )}
+
+
+ {content}
+
+ )
+}
+
+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/actions-menu/ActionsMenu.module.scss b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss
new file mode 100644
index 000000000..e8ad6ecff
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/actions-menu/ActionsMenu.module.scss
@@ -0,0 +1,40 @@
+@import '@libs/ui/styles/includes';
+
+.wrap {
+ position: relative;
+}
+
+.trigger {
+ cursor: pointer;
+
+ padding: $sp-1;
+ margin: -$sp-1;
+
+ + * {
+ z-index: 9;
+ }
+}
+
+.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/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..1ce51a2b1
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/BulkEditor.tsx
@@ -0,0 +1,120 @@
+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 { MoveSkillsModal } from './move-skills-modal'
+import { ReplaceSkillsModal } from './replace-skills-modal'
+import styles from './BulkEditor.module.scss'
+
+interface BulkEditorProps {
+ className?: string
+}
+
+const BulkEditor: FC = props => {
+ const {
+ bulkEditorCtx: context,
+ refetchSkills,
+ skillsList,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ const [showArchive, setShowArchive] = useState(false)
+ const [showReplaceSkills, setShowReplaceSkills] = useState(false)
+ const [showMoveSkills, setShowMoveSkills] = useState(false)
+
+ function openArchiveModal(): void {
+ setShowArchive(true)
+ }
+
+ function openMoveSkillsModal(): void {
+ setShowMoveSkills(true)
+ }
+
+ function openReplaceSkillsModal(): void {
+ setShowReplaceSkills(true)
+ }
+
+ function closeArchiveModal(archived?: boolean): void {
+ if (archived === true) {
+ refetchSkills()
+ context.toggleAll()
+ }
+
+ setShowArchive(false)
+ }
+
+ function closeMoveSkillsModal(moved?: boolean): void {
+ if (moved === true) {
+ refetchSkills()
+ context.toggleAll()
+ }
+
+ setShowMoveSkills(false)
+ }
+
+ function closeReplaceSkillsModal(replaced?: boolean): void {
+ if (replaced === true) {
+ refetchSkills()
+ context.toggleAll()
+ }
+
+ setShowReplaceSkills(false)
+ }
+
+ const hasSelection = context.selectedSkills.length > 0
+
+ return (
+
+
+
+
+
+
+ {showArchive && (
+
+ )}
+
+ {showReplaceSkills && (
+
+ )}
+
+ {showMoveSkills && (
+
+ )}
+
+ )
+}
+
+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..62225e917
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/ArchiveSkillsModal.tsx
@@ -0,0 +1,64 @@
+import { FC, useEffect, useState } from 'react'
+
+import { BaseModal, Button, LoadingSpinner } from '~/libs/ui'
+
+import { SkillsList } from '../../skills-list'
+import { bulkArchiveStandardizedSkills, StandardizedSkill } from '../../../services'
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../../context'
+
+import styles from './ArchiveSkillsModal.module.scss'
+
+interface ArchiveSkillsModalProps {
+ skills: StandardizedSkill[]
+ onClose: (archived?: boolean) => void
+}
+
+const ArchiveSkillsModal: FC = props => {
+ const [isLoading, setIsLoading] = useState(false)
+
+ const { bulkEditorCtx: context }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ async function archiveAll(): Promise {
+ setIsLoading(true)
+ await bulkArchiveStandardizedSkills(props.skills)
+ props.onClose(true)
+ setIsLoading(false)
+ }
+
+ function close(): void {
+ props.onClose()
+ }
+
+ useEffect(() => {
+ if (!props.skills.length) {
+ props.onClose.call(undefined)
+ }
+ }, [props.onClose, props.skills])
+
+ return (
+
+
+
+ >
+ )}
+ >
+
+
+
+ )
+}
+
+export default ArchiveSkillsModal
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/index.ts b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/index.ts
new file mode 100644
index 000000000..5ac13a89c
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/archive-skills-modal/index.ts
@@ -0,0 +1 @@
+export { default as ArchiveSkillsModal } from './ArchiveSkillsModal'
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/index.ts b/src/apps/admin/src/skills-manager/components/bulk-editor/index.ts
new file mode 100644
index 000000000..e0f75f5ab
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/index.ts
@@ -0,0 +1 @@
+export { default as BulkEditor } from './BulkEditor'
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.module.scss b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.module.scss
new file mode 100644
index 000000000..689cd9aee
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.module.scss
@@ -0,0 +1,15 @@
+@import '@libs/ui/styles/includes';
+
+.modalBody {
+ .skillsList {
+ column-count: 2;
+ margin: -$sp-2;
+ margin-bottom: $sp-3;
+ }
+}
+
+.formInput {
+ &Label {
+ margin-bottom: $sp-1;
+ }
+}
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.tsx b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.tsx
new file mode 100644
index 000000000..bc69fc71b
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/MoveSkillsModal.tsx
@@ -0,0 +1,139 @@
+import { ChangeEvent, FC, useEffect, useMemo, useState } from 'react'
+import { noop } from 'lodash'
+import { toast } from 'react-toastify'
+
+import { BaseModal, Button, InputSelectReact, LoadingSpinner } from '~/libs/ui'
+
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../../context'
+import {
+ bulkUpdateStandardizedSkills,
+ saveStandardizedSkillCategory,
+ StandardizedSkill,
+ StandardizedSkillCategory,
+} from '../../../services'
+import { mapCategoryToSelectOption } from '../../../lib'
+import { SkillsList } from '../../skills-list'
+
+import styles from './MoveSkillsModal.module.scss'
+
+interface MoveSkillsModalProps {
+ skills: StandardizedSkill[]
+ onClose: (archived?: boolean) => void
+}
+
+const MoveSkillsModal: FC = props => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [categoryId, setCategoryId] = useState()
+
+ const {
+ bulkEditorCtx: context,
+ refetchCategories,
+ categories,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ const currentCategoryOptions = useMemo(() => (
+ mapCategoryToSelectOption([props.skills[0]?.category].filter(Boolean))
+ ), [props.skills])
+
+ const categoryOptions = useMemo(() => mapCategoryToSelectOption(categories), [categories])
+
+ function handleCategoryChange(ev: ChangeEvent): void {
+ setCategoryId(ev.target.value)
+ }
+
+ async function moveAll(): Promise {
+ setIsLoading(true)
+ await bulkUpdateStandardizedSkills(props.skills, { categoryId })
+ props.onClose(true)
+ setIsLoading(false)
+ }
+
+ function close(): void {
+ props.onClose()
+ }
+
+ async function handleNewCategory(categoryName: string): Promise {
+ setIsLoading(true)
+
+ const newCategory = await saveStandardizedSkillCategory(
+ { description: ' ', name: categoryName } as StandardizedSkillCategory,
+ )
+
+ refetchCategories()
+
+ setCategoryId(newCategory.id)
+ toast.success(`Category with name '${categoryName}' created!`)
+ setIsLoading(false)
+ }
+
+ useEffect(() => {
+ if (!props.skills.length) {
+ props.onClose.call(undefined)
+ }
+ }, [props.onClose, props.skills])
+
+ return (
+
+
+
+ >
+ )}
+ >
+
+
+
+
+
+
+
+ )
+}
+
+export default MoveSkillsModal
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/index.ts b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/index.ts
new file mode 100644
index 000000000..d3618622f
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/move-skills-modal/index.ts
@@ -0,0 +1 @@
+export { default as MoveSkillsModal } from './MoveSkillsModal'
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.module.scss b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.module.scss
new file mode 100644
index 000000000..0c860eac5
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.module.scss
@@ -0,0 +1,18 @@
+@import '@libs/ui/styles/includes';
+
+.modalBody {
+ .skillsList {
+ column-count: 2;
+ margin: -$sp-2;
+ margin-bottom: $sp-3;
+ }
+}
+
+.formInput {
+ &Label {
+ display: flex;
+ align-items: center;
+ gap: $sp-6;
+ margin-bottom: $sp-2;
+ }
+}
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.tsx b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.tsx
new file mode 100644
index 000000000..55c784ec3
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/ReplaceSkillsModal.tsx
@@ -0,0 +1,128 @@
+import { ChangeEvent, FC, useEffect, useState } from 'react'
+import { find } from 'lodash'
+
+import { BaseModal, Button, InputRadio, LoadingSpinner } from '~/libs/ui'
+
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../../context'
+import { StandardizedSkill } from '../../../services'
+import { SkillsList } from '../../skills-list'
+import { SearchSkillInput } from '../search-skill-input'
+import { SkillForm } from '../../skill-modals'
+
+import styles from './ReplaceSkillsModal.module.scss'
+
+interface ReplaceSkillsModalProps {
+ allSkills: StandardizedSkill[]
+ skills: StandardizedSkill[]
+ onClose: (archived?: boolean) => void
+}
+
+const ReplaceSkillsModal: FC = props => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [type, setType] = useState<'existing'|'new'>('existing')
+ const [replacingSkill, setReplacingSkill] = useState()
+
+ const {
+ bulkEditorCtx: context,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ async function replaceAll(): Promise {
+ setIsLoading(true)
+ // TODO: call api to replace skills
+ props.onClose(true)
+ setIsLoading(false)
+ }
+
+ function close(): void {
+ props.onClose()
+ }
+
+ function handleSkillSelect(event: ChangeEvent): void {
+ setReplacingSkill(find(props.allSkills, { id: event.target.value }))
+ }
+
+ function handleNewForm(skillData: Partial, dataIsValid: boolean):void {
+ setReplacingSkill(dataIsValid ? skillData as StandardizedSkill : undefined)
+ }
+
+ function toggleType(t: 'existing'|'new'): void {
+ setReplacingSkill(undefined)
+ setType(t)
+ }
+
+ useEffect(() => {
+ if (!props.skills.length) {
+ props.onClose.call(undefined)
+ }
+ }, [props.onClose, props.skills])
+
+ return (
+
+
+
+ >
+ )}
+ >
+
+
+
+
+
With:
+
+
+
+ {type === 'existing' && (
+
+ )}
+ {type === 'new' && (
+
void}
+ hideCancelBtn
+ hideSaveBtn
+ />
+ )}
+
+
+
+
+ )
+}
+
+export default ReplaceSkillsModal
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/index.ts b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/index.ts
new file mode 100644
index 000000000..470606ac4
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/replace-skills-modal/index.ts
@@ -0,0 +1 @@
+export { default as ReplaceSkillsModal } from './ReplaceSkillsModal'
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/SearchSkillInput.tsx b/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/SearchSkillInput.tsx
new file mode 100644
index 000000000..4d1228db9
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/SearchSkillInput.tsx
@@ -0,0 +1,48 @@
+import { ChangeEvent, FC, useMemo } from 'react'
+import { escapeRegExp } from 'lodash'
+
+import { InputSelectOption, InputSelectReact } from '~/libs/ui'
+
+import { mapSkillToSelectOption } from '../../../lib'
+import { StandardizedSkill } from '../../../services'
+
+interface SearchSkillInputProps {
+ skills: StandardizedSkill[]
+ onChange: (event: ChangeEvent) => void
+}
+
+const normalize = (s: string): string => (s || '')
+ .trim()
+ .toLowerCase()
+
+const SearchSkillInput: FC = props => {
+ const skillsOptionsList = useMemo(() => (
+ mapSkillToSelectOption(props.skills)
+ ), [props.skills])
+
+ function filterOptions(o: InputSelectOption, v: string): boolean {
+ const normValue = normalize(v)
+ const normLabel = normalize(o.label as string)
+
+ if (v.length < 3 && normValue !== normLabel) {
+ return false
+ }
+
+ const m = new RegExp(escapeRegExp(normValue), 'i')
+ return !!normLabel.match(m)
+ }
+
+ return (
+
+ )
+}
+
+export default SearchSkillInput
diff --git a/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/index.ts b/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/index.ts
new file mode 100644
index 000000000..fe82aefa7
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/bulk-editor/search-skill-input/index.ts
@@ -0,0 +1 @@
+export { default as SearchSkillInput } from './SearchSkillInput'
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..217f042a0
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/categories-accordion/CategoriesAccordion.tsx
@@ -0,0 +1,94 @@
+import { FC, useCallback, useMemo } from 'react'
+
+import { Accordion, AccordionItem } from '../accordion'
+import { SkillsList } from '../skills-list'
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../context'
+import { StandardizedSkill, StandardizedSkillCategory } from '../../services'
+import { CATEGORY_ITEM_ACTIONS, MENU_ACTIONS } from '../../config'
+
+interface CategoriesAccordionProps {
+ defaultOpen?: boolean
+}
+
+const CategoriesAccordion: FC = props => {
+ const {
+ setEditSkill,
+ skillsFilter,
+ setEditCategory,
+ categories,
+ groupedSkills,
+ bulkEditorCtx,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ const handleMenuActions = useCallback((action: string, category: StandardizedSkillCategory): void => {
+ switch (action) {
+ case MENU_ACTIONS.editCategory.action:
+ setEditCategory(category)
+ break
+ case MENU_ACTIONS.bulkEditSkills.action:
+ bulkEditorCtx.toggle(category)
+ break
+ default: break
+ }
+ }, [bulkEditorCtx, setEditCategory])
+
+ const handleBulkEditClick = useCallback((skill: StandardizedSkill) => {
+ bulkEditorCtx.toggle(skill.category)
+ }, [bulkEditorCtx])
+
+ const renderCategoryAccordion = useCallback((category: StandardizedSkillCategory): JSX.Element => {
+ const categorySkills = groupedSkills[category.id] ?? []
+
+ return (!skillsFilter || categorySkills.length > 0) ? (
+
+ {() => (
+
+ )}
+
+ ) : <>>
+ }, [
+ bulkEditorCtx.isEditing,
+ bulkEditorCtx.isSkillSelected,
+ bulkEditorCtx.toggleSkill,
+ groupedSkills,
+ handleMenuActions,
+ props.defaultOpen,
+ setEditSkill,
+ skillsFilter,
+ ])
+
+ // use a memo to persist the items rendering
+ // otheriwse, the order and mapping of the categories
+ // will trigger a new re-render of the accordion on each context change
+ const accordionItems = useMemo(() => (
+ !!bulkEditorCtx.isEditing ? (
+ renderCategoryAccordion(bulkEditorCtx.isEditing)
+ ) : (
+ categories
+ .map(renderCategoryAccordion)
+ )
+ ), [bulkEditorCtx.isEditing, categories, renderCategoryAccordion])
+
+ return (
+
+ {accordionItems}
+
+ )
+}
+
+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/category-modal/CategoryModal.module.scss b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss
new file mode 100644
index 000000000..f43ce098c
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.module.scss
@@ -0,0 +1,34 @@
+@import '@libs/ui/styles/includes';
+
+.modalBody {
+ form {
+ :global(.page-divider) {
+ display: none;
+ }
+
+ :global(.input-wrapper) {
+ > :global(.input-error) {
+ margin-bottom: 8px;
+ }
+ }
+ }
+}
+
+.error {
+ display: flex;
+ align-items: flex-start;
+ color: $red-100;
+
+ font-family: $font-roboto;
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: $font-weight-normal;
+ margin-bottom: $sp-4;
+
+ svg {
+ @include icon-lg;
+ fill: $red-100;
+ margin-right: $sp-1;
+ margin-top: 2px;
+ }
+}
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..b35990dd0
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/category-modal/CategoryModal.tsx
@@ -0,0 +1,100 @@
+import { FC, useCallback, useMemo, useState } from 'react'
+import { toast } from 'react-toastify'
+import classNames from 'classnames'
+
+import { BaseModal, Form, formGetInputModel, FormInputModel, FormValue, IconSolid, LoadingSpinner } from '~/libs/ui'
+
+import {
+ archiveStandardizedSkillCategory,
+ saveStandardizedSkillCategory,
+ StandardizedSkillCategory,
+} from '../../services'
+
+import { categoryFormDef, CategoryFormField, validateUniqueCategoryName } from './category-form.config'
+import styles from './CategoryModal.module.scss'
+
+interface CategoryModalProps {
+ categories: StandardizedSkillCategory[]
+ category: StandardizedSkillCategory
+ onClose: () => void
+ onSave: () => void
+}
+
+const CategoryModal: FC = props => {
+ const action = props.category?.id ? 'edit' : 'add'
+
+ const [error, setError] = useState()
+ 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(() => {
+ props.onSave.call(undefined)
+ props.onClose.call(undefined)
+ toast.success(`${action === 'edit' ? 'Changes' : 'Category'} saved!`)
+ })
+ .catch((e: any) => {
+ setLoading(false)
+ return Promise.reject(e)
+ })
+ }, [action, props.onClose, props.onSave])
+
+ const archiveCategory = useCallback(async (): Promise => {
+ setLoading(true)
+
+ return archiveStandardizedSkillCategory(props.category)
+ .then(() => {
+ props.onSave?.call(undefined)
+ props.onClose?.call(undefined)
+ toast.success(`Category ${props.category.name} archived successfully!`)
+ })
+ .catch((e: any) => {
+ setError((e.message ?? '').replace(/with id [a-z0-9-]+/i, ''))
+ setLoading(false)
+ return Promise.reject(e)
+ })
+ }, [props.onClose, props.onSave, props.category])
+
+ const formDef = useMemo(() => categoryFormDef(
+ props.onClose,
+ validateUniqueCategoryName(props.categories, props.category),
+ action === 'edit' ? archiveCategory : undefined,
+ ), [action, archiveCategory, props.categories, props.category, props.onClose])
+
+ return (
+
+
+
+
+ )
+}
+
+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..e48773555
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/category-modal/category-form.config.ts
@@ -0,0 +1,92 @@
+import { escapeRegExp } from 'lodash'
+
+import { customValidatorRequired, FormDefinition, InputValue } from '~/libs/ui'
+
+import { StandardizedSkillCategory } from '../../services'
+
+export enum CategoryFormField {
+ name = 'name',
+ description = 'description',
+}
+
+type ValidatorType = (value: InputValue) => string | undefined
+
+export const validateUniqueCategoryName = (
+ categories: StandardizedSkillCategory[],
+ category: StandardizedSkillCategory,
+) => (
+ (value: InputValue): string | undefined => {
+ const filterRegex = new RegExp(`^${escapeRegExp((value as string || '').trim())}$`, 'i')
+ const match = categories.find(cat => filterRegex.test(cat.name))
+ return match && match.id !== category.id
+ ? 'A category with the same name already exists!' : undefined
+ }
+)
+
+export const categoryFormDef = (
+ onClose: () => void,
+ nameValidator: ValidatorType,
+ onArchive?: () => void,
+): FormDefinition => ({
+ buttons: {
+ primaryGroup: [
+ {
+ buttonStyle: 'secondary',
+ isSubmit: false,
+ label: 'Cancel',
+ onClick: onClose,
+ size: 'lg',
+ type: 'button',
+ },
+ {
+ buttonStyle: 'primary',
+ isSubmit: true,
+ label: 'Save',
+ size: 'lg',
+ type: 'submit',
+ },
+ ],
+ secondaryGroup: onArchive ? [
+ {
+ buttonStyle: 'secondary',
+ isSubmit: false,
+ label: 'Archive',
+ onClick: onArchive,
+ size: 'lg',
+ type: 'button',
+ variant: 'danger',
+ },
+ ] : undefined,
+ },
+ groups: [
+ {
+ inputs: [
+ {
+ label: 'Category Name',
+ name: CategoryFormField.name,
+ placeholder: 'Enter category name',
+ type: 'text',
+ validators: [
+ {
+ validator: customValidatorRequired('Category name is required!'),
+ },
+ {
+ validator: nameValidator,
+ },
+ ],
+ },
+ {
+ label: 'Description',
+ name: CategoryFormField.description,
+ type: 'textarea',
+ validators: [
+ {
+ validator: customValidatorRequired('Category description is required!'),
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ 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
new file mode 100644
index 000000000..6d83599f5
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/index.ts
@@ -0,0 +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-modals'
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..8ec55ed1a
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/more-actions-menu/MoreActionsMenu.module.scss
@@ -0,0 +1,31 @@
+@import '@libs/ui/styles/includes';
+
+.stickyContainer {
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 100%;
+ width: 0;
+ &::after {
+ content: '';
+ display: block;
+ height: 100%;
+ }
+}
+
+.floatingActionBtn {
+ position: sticky;
+ right: 0;
+ top: 0;
+}
+
+.triggerBtn.triggerBtn {
+ padding-left: $sp-4;
+ padding-right: $sp-4;
+}
+
+.menu {
+ position: absolute;
+ right: 0;
+ top: $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..586088cd1
--- /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 '../../context/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/page-header/PageHeader.module.scss b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss
new file mode 100644
index 000000000..e391795dd
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.module.scss
@@ -0,0 +1,15 @@
+@import '@libs/ui/styles/includes';
+
+.wrap {
+ padding: $sp-8 0 0;
+}
+
+.inner {
+ display: flex;
+ align-items: center;
+ gap: $sp-8;
+
+ h1 {
+ line-height: $sp-10;
+ }
+}
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..c26ee20bb
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/page-header/PageHeader.tsx
@@ -0,0 +1,21 @@
+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..305fda8f7
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/search-input/SearchInput.tsx
@@ -0,0 +1,71 @@
+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 => {
+ if (newValue && newValue.length === 1) {
+ return
+ }
+
+ 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/skill-modals/SkillModal.module.scss b/src/apps/admin/src/skills-manager/components/skill-modals/SkillModal.module.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/SkillModal.tsx b/src/apps/admin/src/skills-manager/components/skill-modals/SkillModal.tsx
new file mode 100644
index 000000000..816d37863
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/SkillModal.tsx
@@ -0,0 +1,156 @@
+import { toast } from 'react-toastify'
+import {
+ FC,
+ useCallback,
+ useMemo,
+ useState,
+} from 'react'
+
+import {
+ BaseModal,
+ Button,
+ LoadingSpinner,
+ useConfirmationModal,
+} from '~/libs/ui'
+
+import {
+ archiveStandardizedSkill,
+ restoreArchivedStandardizedSkill,
+ saveStandardizedSkill,
+ StandardizedSkill,
+} from '../../services'
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../context'
+import { isSkillArchived } from '../../lib'
+
+import SkillForm from './skill-form/SkillForm'
+
+interface SkillModalProps {
+ skill: StandardizedSkill
+}
+
+const SkillModal: FC = props => {
+ const {
+ refetchSkills,
+ setEditSkill,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ const confirmModal = useConfirmationModal()
+
+ const [isLoading, setIsLoading] = useState(false)
+ const isArchived = useMemo(() => isSkillArchived(props.skill), [props.skill])
+
+ const action = isArchived ? 'restore' : props.skill?.id ? 'edit' : 'add'
+
+ function close(): void {
+ setEditSkill()
+ }
+
+ const saveAsync = useCallback(async (skillData: Partial): Promise => {
+ setIsLoading(true)
+
+ return saveStandardizedSkill({
+ ...skillData,
+ id: props.skill.id as string,
+ } as StandardizedSkill)
+ .then(() => {
+ refetchSkills()
+ setEditSkill()
+ toast.success(`${action === 'edit' ? 'Changes' : 'Skill'} saved!`)
+ })
+ .finally(() => setIsLoading(false))
+ }, [props.skill.id, refetchSkills, setEditSkill, action])
+
+ const addAnother = useCallback(async (): Promise => {
+ setTimeout(setEditSkill, 750, {} as StandardizedSkill)
+ }, [setEditSkill])
+
+ const archiveSkill = useCallback(async (): Promise => {
+ const confirmed = await confirmModal.confirm({
+ content: 'Are you sure you want to archive this skill?',
+ title: 'Confirm Archive',
+ })
+
+ if (!confirmed) {
+ return undefined
+ }
+
+ setIsLoading(true)
+ return archiveStandardizedSkill(props.skill)
+ .then(() => {
+ refetchSkills()
+ setEditSkill()
+ toast.success(`Skill ${props.skill.name} archived successfully!`)
+ })
+ .catch((e: any) => {
+ setIsLoading(false)
+ return Promise.reject(e)
+ })
+ }, [confirmModal, props.skill, refetchSkills, setEditSkill])
+
+ const restoreSkill = useCallback(async (): Promise => {
+ setIsLoading(true)
+
+ // eslint-disable-next-line unicorn/no-null
+ return restoreArchivedStandardizedSkill({ ...props.skill, deleted_at: null })
+ .then(() => {
+ refetchSkills()
+ setEditSkill()
+ toast.success(`Skill ${props.skill.name} restored successfully!`)
+ })
+ .catch((e: any) => {
+ setIsLoading(false)
+ return Promise.reject(e)
+ })
+ }, [setEditSkill, props.skill, refetchSkills])
+
+ const renderSaveAndAddBtn = useCallback((isFormValid: boolean) => action === 'add' && (
+
+ ), [action, addAnother])
+
+ return (
+
+ void}
+ onCancel={close}
+ isDisabled={isArchived}
+ secondaryButtons={action !== 'add' && (
+ !isArchived ? (
+
+ ) : (
+
+ )
+ )}
+ primaryButtons={renderSaveAndAddBtn}
+ />
+
+ {confirmModal.modal}
+
+ )
+}
+
+export default SkillModal
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/index.ts b/src/apps/admin/src/skills-manager/components/skill-modals/index.ts
new file mode 100644
index 000000000..930f2b00f
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/index.ts
@@ -0,0 +1,2 @@
+export { default as SkillModal } from './SkillModal'
+export * from './skill-form'
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.module.scss b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.module.scss
new file mode 100644
index 000000000..18a0a427d
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.module.scss
@@ -0,0 +1,44 @@
+@import '@libs/ui/styles/includes';
+
+.wrap {
+ position: relative;
+
+ &.isDirty> :global(.input-wrapper):focus-within + .dropdown {
+ display: block;
+ }
+}
+
+.dropdown {
+ position: absolute;
+ top: $sp-12;
+ left: 0;
+ right: 0;
+
+ background: $tc-white;
+
+ border: 1px solid $black-20;
+ border-radius: $sp-1;
+ padding: $sp-2;
+
+ z-index: 9;
+
+ max-height: 160px;
+ overflow: auto;
+
+ display: none;
+}
+
+.categoryList {
+ > li + li {
+ margin-top: $sp-4;
+ }
+}
+
+.skillList {
+ margin-top: $sp-2;
+ margin-left: $sp-4;
+
+ > li + li {
+ margin-top: $sp-2;
+ }
+}
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.tsx b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.tsx
new file mode 100644
index 000000000..ddf362e22
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/SimilarSkillsDropdown.tsx
@@ -0,0 +1,50 @@
+import { FC, PropsWithChildren, useMemo } from 'react'
+import classNames from 'classnames'
+
+import { StandardizedSkill, StandardizedSkillCategory } from '../../../services'
+import { findSkillsMatches, groupSkillsByCategory } from '../../../lib'
+
+import styles from './SimilarSkillsDropdown.module.scss'
+
+interface SimilarSkillsDropdownProps extends PropsWithChildren {
+ categories: StandardizedSkillCategory[]
+ skillName?: string
+ skills: StandardizedSkill[]
+ isInputDirty?: boolean
+}
+
+const SimilarSkillsDropdown: FC = props => {
+ const skillsList = useMemo(() => (
+ (props.skillName ? findSkillsMatches(props.skills ?? [], props.skillName) : [])
+ ), [props.skillName, props.skills])
+
+ const groupedSkills = useMemo(() => groupSkillsByCategory(skillsList), [skillsList])
+
+ const categories = useMemo(() => (
+ props.categories.filter(c => !!groupedSkills[c.id])
+ ), [props.categories, groupedSkills])
+
+ return (
+
+ {props.children}
+ {props.skillName && skillsList.length > 0 && (
+
+
+ {categories.map(category => (
+ -
+
{category.name}
+
+ {groupedSkills[category.id].map(skill => (
+ - {skill.name}
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export default SimilarSkillsDropdown
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/index.ts b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/index.ts
new file mode 100644
index 000000000..a8f1bd3fb
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/similar-skills-dropdown/index.ts
@@ -0,0 +1 @@
+export { default as SimilarSkillsDropdown } from './SimilarSkillsDropdown'
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.module.scss b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.module.scss
new file mode 100644
index 000000000..c25d0029a
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.module.scss
@@ -0,0 +1,23 @@
+@import '@libs/ui/styles/includes';
+
+.form {
+ :global(.input-wrapper) {
+ > :global(.input-error) {
+ margin-bottom: 8px;
+ }
+ }
+}
+
+
+.buttonsWrap {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+}
+
+.primaryGroup {
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+ gap: $sp-2;
+}
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.tsx b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.tsx
new file mode 100644
index 000000000..36f35fa97
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/SkillForm.tsx
@@ -0,0 +1,257 @@
+import { ChangeEvent, FC, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
+import { escapeRegExp, pick } from 'lodash'
+import { toast } from 'react-toastify'
+
+import { Button, FormInputAutocompleteOption, InputSelectReact, InputText, InputTextarea } from '~/libs/ui'
+
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../../../context'
+import { mapCategoryToSelectOption } from '../../../lib'
+import {
+ saveStandardizedSkillCategory,
+ StandardizedSkill,
+ StandardizedSkillCategory,
+} from '../../../services'
+import { SimilarSkillsDropdown } from '../similar-skills-dropdown'
+
+import styles from './SkillForm.module.scss'
+
+interface SkillFormProps {
+ isDisabled?: boolean
+ skill?: StandardizedSkill
+ onSave?: (skillData: Partial) => void
+ onChange?: (skillData: Partial, isValid: boolean) => void
+ onCancel?: () => void
+ onLoading: (loading?: boolean) => void
+
+ secondaryButtons?: ReactNode
+ primaryButtons?: (isFormValid: boolean) => ReactNode
+
+ hideCancelBtn?: boolean
+ hideSaveBtn?: boolean
+ saveBtnLabel?: string
+}
+
+const SkillForm: FC = props => {
+ const {
+ categories,
+ refetchCategories,
+ allSkills: skillsList,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ const [forceUpdate, setForceUpdate] = useState(false)
+ const [formValue, setFormValue] = useState({} as Pick)
+ const [formState, setFormState] = useState({
+ categoryId: { dirty: false, error: undefined as string | undefined },
+ description: { dirty: false, error: undefined as string | undefined },
+ name: { dirty: false, error: undefined as string | undefined },
+ })
+
+ const categoryOptions = useMemo(() => mapCategoryToSelectOption(categories ?? []), [categories])
+
+ const isLoading = useCallback((loading?: boolean): void => {
+ props.onLoading.call(undefined, loading)
+ }, [props.onLoading])
+
+ function handleFormChanges(ev: ChangeEvent): void {
+ setFormValue(prevValue => ({
+ ...prevValue,
+ [ev.target.name]: ev.target.value,
+ }))
+
+ setFormState(prev => ({
+ ...prev,
+ [ev.target.name]: { dirty: true, error: undefined },
+ }))
+ }
+
+ function validateName(): void {
+ const name = formValue.name?.trim() ?? ''
+ const isValid = name.length > 0
+ const filterRegex = new RegExp(`^${escapeRegExp(name)}$`, 'i')
+ const similarSkill = skillsList.find(skill => filterRegex.test(skill.name))
+ const isDuplicate = !!similarSkill && similarSkill.id !== props.skill?.id
+
+ const error = !isValid ? 'Skill name is required!' : (
+ isDuplicate ? 'A skill with the same name already exists!' : undefined
+ )
+
+ setFormState(prev => ({
+ ...prev,
+ name: { ...prev.name, error },
+ }))
+ }
+
+ function validateDescription(): void {
+ const description = formValue.description?.trim() ?? ''
+ const isValid = description.length > 0
+ const error = !isValid ? 'Skill description is required!' : undefined
+
+ setFormState(prev => ({
+ ...prev,
+ description: { ...prev.description, error },
+ }))
+ }
+
+ function validateCategory(): void {
+ const categoryId = formValue.categoryId?.trim() ?? ''
+ const isValid = categoryId.length > 0
+ const error = !isValid ? 'Skill category is required!' : undefined
+
+ setFormState(prev => ({
+ ...prev,
+ categoryId: { ...prev.categoryId, error },
+ }))
+ }
+
+ function validateForm(): void {
+ validateName()
+ validateDescription()
+ validateCategory()
+ }
+
+ function handleFormSubmit(ev?: any): void {
+ ev?.preventDefault?.()
+ props.onSave?.(formValue)
+ }
+
+ const isFormValid = useMemo(() => (
+ !Object.entries(formState)
+ .find(([, value]) => !!value.error)
+ ), [formState])
+
+ async function handleNewCategory(categoryName: string): Promise {
+ isLoading(true)
+
+ const newCategory = await saveStandardizedSkillCategory(
+ { description: ' ', name: categoryName } as StandardizedSkillCategory,
+ )
+
+ refetchCategories()
+
+ handleFormChanges({
+ target: {
+ name: 'categoryId',
+ value: newCategory.id,
+ },
+ } as ChangeEvent)
+ toast.success(`Category with name '${categoryName}' created!`)
+ isLoading(false)
+ }
+
+ useEffect(() => {
+ if (!props.skill) {
+ return
+ }
+
+ // when skill object changes, persist the new props into formValue state
+ setFormValue({
+ categoryId: props.skill.category?.id,
+ ...pick(props.skill, ['name', 'description', 'categoryId']),
+ })
+
+ setFormState({
+ categoryId: { dirty: false, error: props.skill.category?.id ? undefined : 'required' },
+ description: { dirty: false, error: props.skill.description ? undefined : 'required' },
+ name: { dirty: false, error: props.skill.name ? undefined : 'required' },
+ })
+
+ setForceUpdate(true)
+ }, [props.skill])
+
+ useEffect(() => {
+ validateForm()
+ }, [formValue])
+
+ useEffect(() => {
+ props.onChange?.call(undefined, formValue, isFormValid)
+ }, [props.onChange, formValue, formState, isFormValid])
+
+ useEffect(() => {
+ if (forceUpdate) {
+ setForceUpdate(false)
+ }
+ }, [forceUpdate])
+
+ return (
+
+ )
+}
+
+export default SkillForm
diff --git a/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/index.ts b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/index.ts
new file mode 100644
index 000000000..dd953e156
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skill-modals/skill-form/index.ts
@@ -0,0 +1 @@
+export { default as SkillForm } from './SkillForm'
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..65bc52f57
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.module.scss
@@ -0,0 +1,39 @@
+@import '@libs/ui/styles/includes';
+
+.wrap {
+ column-count: 4;
+}
+
+.listWrap {
+ display: flex;
+ flex-direction: column;
+}
+
+.skillItem {
+ padding: $sp-2 $sp-2;
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+
+ cursor: pointer;
+ transition: 0.15s ease-in-out;
+
+ &:hover {
+ background: rgba($tc-black, 0.075);
+ }
+
+ &.archived {
+ color: $black-40;
+ // pointer-events: none;
+ }
+}
+
+.checkbox {
+ > * {
+ margin: -$sp-2;
+ > * {
+ display: block;
+ padding: $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..87041af4a
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/components/skills-list/SkillsList.tsx
@@ -0,0 +1,78 @@
+import { FC, MouseEvent } from 'react'
+import classNames from 'classnames'
+
+import { InputCheckbox } from '~/libs/ui'
+
+import { StandardizedSkill } from '../../services'
+import { isSkillArchived } from '../../lib'
+
+import styles from './SkillsList.module.scss'
+
+interface SkillsListProps {
+ className?: string
+ skills: StandardizedSkill[]
+ editMode?: boolean
+ onSelect: (skill: StandardizedSkill) => void
+ isSelected: (skill: StandardizedSkill) => boolean
+ onEditSkill?: (skill: StandardizedSkill) => void
+ onBulkEditSkill?: (skill: StandardizedSkill) => void
+}
+
+const SkillsList: FC = props => {
+ const handleSkillItemClick = (skill: StandardizedSkill) => (ev: MouseEvent) => {
+ // prevent when clicking the checkbox
+ const targetEl = ev.target as HTMLInputElement
+ if (targetEl.nodeName === 'INPUT' && targetEl.type?.toLowerCase() === 'checkbox') {
+ return
+ }
+
+ // trigger bulk edit when holding ctrl key and bulk editor is not active
+ if (ev.ctrlKey && !props.editMode && props.onBulkEditSkill) {
+ props.onBulkEditSkill(skill)
+ setTimeout(props.onSelect, 100, skill)
+ return
+ }
+
+ // toggle skill selection when holding ctrl key (rather than triggering skill edit)
+ if (ev.ctrlKey) {
+ props.onSelect(skill)
+ return
+ }
+
+ props.onEditSkill?.(skill)
+ }
+
+ const handleCheckboxToggle = (skill: StandardizedSkill) => () => {
+ props.onSelect(skill)
+ }
+
+ return (
+
+
+ {props.skills.map(skill => (
+ -
+ {props.editMode && (
+
+
+
+ )}
+
+ {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/config/constants.ts b/src/apps/admin/src/skills-manager/config/constants.ts
new file mode 100644
index 000000000..98911a1b0
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/config/constants.ts
@@ -0,0 +1,44 @@
+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,
+]
+
+export enum BULK_SKILL_ACTIONS {
+ move = 'bulk:move:skills',
+ replace = 'bulk:replace:skills',
+ archive = 'bulk:archive:skills',
+}
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/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/context/skills-manager.context.tsx b/src/apps/admin/src/skills-manager/context/skills-manager.context.tsx
new file mode 100644
index 000000000..180262d3d
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/context/skills-manager.context.tsx
@@ -0,0 +1,105 @@
+import { createContext, FC, ReactNode, useCallback, useContext, useMemo, useState } from 'react'
+import { SWRResponse } from 'swr'
+
+import { StandardizedSkill, StandardizedSkillCategory, useFetchCategories, useFetchSkills } from '../services'
+import { findSkillsMatches, GroupedSkills, groupSkillsByCategory } from '../lib'
+
+import { SkillsBulkEditorContext, useSkillsBulkEditorContext } from './use-skills-bulk-editor-context'
+
+export interface SkillsManagerContextValue {
+ allSkills: StandardizedSkill[],
+ categories: StandardizedSkillCategory[]
+ skillsFilter: string
+ setSkillsFilter: (filter: string) => void
+ skillsList: StandardizedSkill[]
+ groupedSkills: GroupedSkills
+ editCategory: StandardizedSkillCategory | undefined
+ setEditCategory: (group?: StandardizedSkillCategory) => void
+ editSkill: StandardizedSkill | undefined
+ setEditSkill: (skill?: StandardizedSkill) => void
+ refetchCategories: () => void
+ refetchSkills: () => void
+ showArchivedSkills: boolean
+ toggleArchivedSkills: () => void
+ bulkEditorCtx: SkillsBulkEditorContext
+}
+
+const SkillsManagerRC = createContext({
+} as SkillsManagerContextValue)
+
+interface SkillsManagerContextProps {
+ children?: ReactNode
+}
+
+export const SkillsManagerContext: FC = props => {
+ const [showArchivedSkills, setShowArchivedSkills] = useState(false)
+ const [skillsFilter, setSkillsFilter] = useState('')
+ const [editCategory, setEditCategory] = useState()
+ const [editSkill, setEditSkill] = useState()
+
+ const {
+ data: allSkills = [],
+ mutate: refetchSkills,
+ }: SWRResponse = useFetchSkills(true)
+
+ const {
+ data: allCategories = [],
+ mutate: refetchCategories,
+ }: SWRResponse = useFetchCategories()
+
+ const filteredSkills = useMemo(() => (
+ showArchivedSkills ? allSkills : allSkills.filter(s => !s.deleted_at)
+ ), [allSkills, showArchivedSkills])
+
+ const skills = useMemo(() => findSkillsMatches(filteredSkills, skillsFilter), [filteredSkills, skillsFilter])
+ const groupedSkills = useMemo(() => groupSkillsByCategory(skills), [skills])
+
+ const bulkEditorCtx = useSkillsBulkEditorContext(skills)
+
+ const toggleArchivedSkills = useCallback((): void => {
+ setShowArchivedSkills(d => !d)
+ }, [])
+
+ const contextValue = useMemo(() => ({
+ allSkills,
+ bulkEditorCtx,
+ categories: allCategories,
+ editCategory,
+ editSkill,
+ groupedSkills,
+ refetchCategories,
+ refetchSkills,
+ setEditCategory,
+ setEditSkill,
+ setSkillsFilter,
+ showArchivedSkills,
+ skillsFilter,
+ skillsList: skills,
+ toggleArchivedSkills,
+ }), [
+ allSkills,
+ bulkEditorCtx,
+ allCategories,
+ editCategory,
+ editSkill,
+ groupedSkills,
+ refetchCategories,
+ refetchSkills,
+ skills,
+ skillsFilter,
+ showArchivedSkills,
+ toggleArchivedSkills,
+ ])
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+export const useSkillsManagerContext = (): SkillsManagerContextValue => (
+ useContext(SkillsManagerRC)
+)
diff --git a/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor-context.tsx b/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor-context.tsx
new file mode 100644
index 000000000..783f171ee
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/context/use-skills-bulk-editor-context.tsx
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useState } from 'react'
+import { isEmpty } from 'lodash'
+
+import { GenericDataObject } from '~/libs/shared'
+
+import { StandardizedSkill, StandardizedSkillCategory } from '../services'
+import { isSkillArchived } from '../lib'
+
+type SelectedSkillsKeyMap = GenericDataObject
+
+export interface SkillsBulkEditorContext {
+ isEditing: StandardizedSkillCategory | undefined
+ isSkillSelected: (skill: StandardizedSkill) => boolean
+ selectedSkills: StandardizedSkill[]
+ selectAll: () => void
+ toggleAll: () => void
+ toggle: (category?: StandardizedSkillCategory) => void
+ toggleSkill: (skill: StandardizedSkill) => void
+}
+
+export const useSkillsBulkEditorContext = (skills: StandardizedSkill[]): SkillsBulkEditorContext => {
+ const [isEditing, setIsEditing] = useState()
+ const [selectedSkills, setSelectedSkills] = useState([] as StandardizedSkill[])
+ const [selectedSkillsMap, setSelectedSkillsMap] = useState({} as SelectedSkillsKeyMap)
+
+ function toggle(category?: StandardizedSkillCategory): void {
+ setIsEditing(d => (d?.id === category?.id ? undefined : category))
+ }
+
+ const isSkillSelected = useCallback((skill: StandardizedSkill): boolean => (
+ !!selectedSkillsMap[skill.id]
+ ), [selectedSkillsMap])
+
+ const selectSkills = useCallback((skillsMap: SelectedSkillsKeyMap): void => {
+ setSelectedSkillsMap(skillsMap)
+ // eslint-disable-next-line newline-per-chained-call
+ const selected = Object.values(skillsMap).filter(Boolean) as StandardizedSkill[]
+ setSelectedSkills(selected)
+ }, [])
+
+ const toggleSkill = useCallback((skill: StandardizedSkill): void => {
+ if (isSkillArchived(skill)) {
+ return
+ }
+
+ selectSkills({
+ ...selectedSkillsMap,
+ [skill.id]: selectedSkillsMap[skill.id] ? undefined : skill,
+ })
+ }, [selectSkills, selectedSkillsMap])
+
+ const selectAll = useCallback((): void => {
+ const skillsById = skills.reduce((all, skill) => {
+ all[skill.id] = skill
+ return all
+ }, {} as SelectedSkillsKeyMap)
+ selectSkills(skillsById)
+ }, [selectSkills, skills])
+
+ const toggleAll = useCallback((): void => {
+ if (isEmpty(selectedSkillsMap)) {
+ selectAll()
+ return
+ }
+
+ selectSkills({} as SelectedSkillsKeyMap)
+ }, [selectAll, selectSkills, selectedSkillsMap])
+
+ useEffect(() => {
+ selectSkills({} as SelectedSkillsKeyMap)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isEditing])
+
+ return {
+ isEditing,
+ isSkillSelected,
+ selectAll,
+ selectedSkills,
+ toggle,
+ toggleAll,
+ toggleSkill,
+ }
+}
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..5df4c0b90
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.module.scss
@@ -0,0 +1,13 @@
+@import '@libs/ui/styles/includes';
+
+.contentWrap {
+ position: relative;
+}
+
+.contentAccordion {
+ margin-top: $sp-3;
+}
+
+.toRight {
+ margin-left: auto;
+}
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..3400b27f7
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/landing-page/LandingPage.tsx
@@ -0,0 +1,70 @@
+import { FC } from 'react'
+
+import { ContentLayout, InputCheckbox, PageTitle } from '~/libs/ui'
+
+import { SkillsManagerContextValue, useSkillsManagerContext } from '../context'
+import {
+ CategoriesAccordion,
+ CategoryModal,
+ MoreActionsMenu,
+ PageHeader,
+ SearchInput,
+ SkillModal,
+} from '../components'
+import { BulkEditor } from '../components/bulk-editor'
+
+import styles from './LandingPage.module.scss'
+
+const LandingPage: FC<{}> = () => {
+ const {
+ categories,
+ bulkEditorCtx,
+ setSkillsFilter,
+ skillsFilter,
+ editCategory,
+ editSkill: showSkillModal,
+ setEditCategory,
+ refetchCategories,
+ toggleArchivedSkills,
+ showArchivedSkills,
+ }: SkillsManagerContextValue = useSkillsManagerContext()
+
+ return (
+
+ Skills Manager | Admin
+
+
+
+ {!!bulkEditorCtx.isEditing && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {!!editCategory && (
+
+ )}
+
+ {!!showSkillModal && }
+
+ )
+}
+
+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/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..be0f2a43f
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/lib/skills.utils.ts
@@ -0,0 +1,49 @@
+import { escapeRegExp, mapValues, orderBy } from 'lodash'
+
+import { InputSelectOption } from '~/libs/ui'
+
+import { StandardizedSkill, StandardizedSkillCategory } from '../services'
+
+export interface GroupedSkills {
+ [id: string]: StandardizedSkill[]
+}
+
+export const isSkillArchived = (skill: StandardizedSkill): boolean => (
+ !!skill.deleted_at
+)
+
+export const groupSkillsByCategory = (skills: StandardizedSkill[]): GroupedSkills => {
+ const groupedSkills = skills.reduce((grouped, skill) => {
+ const categoryId = skill.category?.id
+ if (!grouped[categoryId]) {
+ grouped[categoryId] = []
+ }
+
+ grouped[categoryId].push(skill)
+
+ return grouped
+ }, {} as GroupedSkills)
+
+ const sortedGroupedSkills = mapValues(groupedSkills, s => orderBy(s, 'name', 'asc'))
+
+ return sortedGroupedSkills
+}
+
+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 ?? '')
+ ))
+}
+
+export const mapCategoryToSelectOption = (
+ categories: StandardizedSkillCategory[],
+): InputSelectOption[] => (
+ categories.map(c => ({ label: c.name, value: c.id }))
+)
+
+export const mapSkillToSelectOption = (
+ skills: StandardizedSkill[],
+): InputSelectOption[] => (
+ skills.map(c => ({ label: c.name, value: c.id }))
+)
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..e67626c3c
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/services/skills-categories.service.ts
@@ -0,0 +1,36 @@
+import { omit } from 'lodash'
+import useSWR, { SWRResponse } from 'swr'
+
+import { EnvironmentConfig } from '~/config'
+import { UserSkillCategory, xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core'
+
+const baseUrl = `${EnvironmentConfig.STANDARDIZED_SKILLS_API}/categories`
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StandardizedSkillCategory extends UserSkillCategory {}
+
+export const useFetchCategories = (): SWRResponse => {
+ const url = `${baseUrl}?disablePagination=true&sortBy=name`
+
+ const response = useSWR(url, xhrGetAsync, {
+ refreshInterval: 0,
+ revalidateOnFocus: false,
+ })
+
+ return response
+}
+
+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'))
+}
+
+export const archiveStandardizedSkillCategory = (category: StandardizedSkillCategory)
+: Promise => {
+ const url = `${baseUrl}/${category.id}`
+
+ return xhrDeleteAsync(url)
+}
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..f506942a8
--- /dev/null
+++ b/src/apps/admin/src/skills-manager/services/skills.service.ts
@@ -0,0 +1,67 @@
+import { omit, pick } from 'lodash'
+import qs from 'qs'
+import useSWR, { SWRResponse } from 'swr'
+
+import { EnvironmentConfig } from '~/config'
+import { UserSkill, xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core'
+
+const baseUrl = `${EnvironmentConfig.STANDARDIZED_SKILLS_API}/skills`
+
+export interface StandardizedSkill extends UserSkill {
+ deleted_at: string | null
+ categoryId?: string
+}
+
+export const useFetchSkills = (showArchived?: boolean): SWRResponse => {
+ const params = qs.stringify({
+ disablePagination: true,
+ showArchived,
+ })
+ const url = `${baseUrl}?${params}`
+
+ const response = useSWR(url, xhrGetAsync, {
+ refreshInterval: 0,
+ revalidateOnFocus: false,
+ })
+
+ return response
+}
+
+export const saveStandardizedSkill = (skill: StandardizedSkill): Promise => {
+ const xhrSaveAsyncFn = skill.id ? xhrPutAsync : xhrPostAsync
+ const url = `${baseUrl}${skill.id ? `/${skill.id}` : ''}`
+
+ return xhrSaveAsyncFn(url, omit(skill, 'id'))
+}
+
+export const archiveStandardizedSkill = (skill: StandardizedSkill): Promise => {
+ const url = `${baseUrl}/${skill.id}`
+
+ return xhrDeleteAsync(url)
+}
+
+export const restoreArchivedStandardizedSkill = (skill: StandardizedSkill): Promise => {
+ const url = `${baseUrl}/${skill.id}/restore`
+
+ return xhrPutAsync(url, {})
+}
+
+export const bulkArchiveStandardizedSkills = (skills: StandardizedSkill[]): Promise => (
+ Promise.all(skills.map(archiveStandardizedSkill))
+)
+
+export const bulkUpdateStandardizedSkills = (
+ skills: StandardizedSkill[],
+ updates: Partial,
+): Promise => {
+ const promises = skills.map(skill => {
+ const url = `${baseUrl}/${skill.id}`
+
+ return xhrPutAsync(
+ url,
+ pick({ ...skill, ...updates }, 'name', 'description', 'categoryId'),
+ ) as Promise
+ })
+
+ return Promise.all(promises)
+}
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..04eea1c7f
--- /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'
+
+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,
+ },
+]
diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx
index 6dd61530c..6c15844fa 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'
import { walletRoutes } from '~/apps/wallet'
const Home: LazyLoadedComponent = lazyLoad(
@@ -38,5 +39,6 @@ export const platformRoutes: Array = [
...profilesRoutes,
...walletRoutes,
...accountsRoutes,
+ ...adminRoutes,
...homeRoutes,
]
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 1275e8e47..479f08052 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -1,4 +1,5 @@
export enum AppSubdomain {
+ admin = 'manage',
accounts = 'account-settings',
devCenter = 'devcenter',
earn = 'earn',
@@ -12,6 +13,7 @@ export enum AppSubdomain {
}
export enum ToolTitle {
+ admin = 'Administration',
accounts = 'Account Settings',
devCenter = 'Dev Center',
earn = 'Opportunity Feed',
diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts
index 0b3d0c3fe..1b9be7af6 100644
--- a/src/config/environments/default.env.ts
+++ b/src/config/environments/default.env.ts
@@ -23,6 +23,8 @@ export const API = {
V5: `https://api.${TC_DOMAIN}/v5`,
}
+export const STANDARDIZED_SKILLS_API = `${API.V5}/standardized-skills`
+
export const AUTH = {
ACCOUNTS_APP_CONNECTOR: `https://accounts-auth0.${TC_DOMAIN}`,
}
diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts
index 2f27a5ef4..63ba75026 100644
--- a/src/config/environments/global-config.model.ts
+++ b/src/config/environments/global-config.model.ts
@@ -8,7 +8,8 @@ export interface GlobalConfig {
V3: string
V4: string
V5: string
- }
+ },
+ STANDARDIZED_SKILLS_API: string,
AUTH: {
ACCOUNTS_APP_CONNECTOR: string
}
diff --git a/src/libs/core/lib/profile/user-skill.model.ts b/src/libs/core/lib/profile/user-skill.model.ts
index 677dc8d76..3670351ad 100644
--- a/src/libs/core/lib/profile/user-skill.model.ts
+++ b/src/libs/core/lib/profile/user-skill.model.ts
@@ -1,6 +1,7 @@
export type UserSkillCategory = {
id: string
name: string
+ description?: string
}
// keep this in sync with the backend
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 }
diff --git a/src/libs/ui/lib/components/button/base-button/BaseButton.module.scss b/src/libs/ui/lib/components/button/base-button/BaseButton.module.scss
index 64fe4566a..aee3a86b4 100644
--- a/src/libs/ui/lib/components/button/base-button/BaseButton.module.scss
+++ b/src/libs/ui/lib/components/button/base-button/BaseButton.module.scss
@@ -53,9 +53,9 @@ $btn-secondary-border-width: $border;
// Define variants
&:global(.btn-variant-danger) {
- --btn-variant: #{$red-110};
- --btn-variant--hover: #{lighten($red-110, 10%)};
- --btn-variant--active: #{darken($red-110, 10%)};
+ --btn-variant: #{$red-120};
+ --btn-variant--hover: #{lighten($red-120, 10%)};
+ --btn-variant--active: #{darken($red-120, 10%)};
}
&:global(.btn-variant-warning) {
--btn-variant: #{$orange-140};
diff --git a/src/libs/ui/lib/components/form/Form.tsx b/src/libs/ui/lib/components/form/Form.tsx
index 6316e7779..d0edc2d76 100644
--- a/src/libs/ui/lib/components/form/Form.tsx
+++ b/src/libs/ui/lib/components/form/Form.tsx
@@ -177,6 +177,8 @@ const Form: <
>
readonly isReset?: boolean
diff --git a/src/libs/ui/lib/components/form/form-functions/form.functions.ts b/src/libs/ui/lib/components/form/form-functions/form.functions.ts
index bfdc15a06..1a52ef73e 100644
--- a/src/libs/ui/lib/components/form/form-functions/form.functions.ts
+++ b/src/libs/ui/lib/components/form/form-functions/form.functions.ts
@@ -192,42 +192,30 @@ function handleFieldEvent(
function validateField(
formInputDef: FormInputModel,
formElements: HTMLFormControlsCollection,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
event: 'blur' | 'change' | 'submit' | 'initial',
): void {
// this is the error the field had before the event took place
const previousError: string | undefined = formInputDef.error
- formInputDef.validators
- ?.forEach(validatorFunction => {
-
- // if the next error is the same as the previous error, then no need to do anything
- const nextError: string | undefined = validatorFunction.validator(
+ // run through all validators and determine the nextError message
+ const nextError = formInputDef.validators
+ ?.reduce((hasError, validatorFunction) => (
+ hasError
+ || validatorFunction.validator(
formInputDef.value,
formElements,
validatorFunction.dependentField,
)
+ ), undefined)
- if (previousError === nextError) {
- return
- }
-
- // we only remove errors on change
- if (event === 'change') {
- if (!nextError) {
- formInputDef.error = undefined
- }
-
- return
- }
+ // if the next error is the same as the previous error, then no need to do anything
+ if (previousError === nextError) {
+ return
+ }
- // this is an on blur or submit event,
- // so if there is no current error for this field,
- // set it to the next error
- if (!formInputDef.error) {
- formInputDef.error = nextError
- }
- })
+ formInputDef.error = nextError
}
export function validateForm(
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 = (
void
+ readonly value?: string
+ readonly id?: string
+ readonly label?: string
+}
+
+const InputCheckbox: FC = props => {
+ const uid = useRef(uniqueId())
+
+ if (props.id && props.id !== uid.current) {
+ uid.current = props.id
+ }
+
+ return (
+
+
+ {props.label && (
+
+ )}
+
+ )
+}
+
+export default InputCheckbox
diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/index.ts b/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/index.ts
new file mode 100644
index 000000000..fcf5b192b
--- /dev/null
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/index.ts
@@ -0,0 +1 @@
+export { default as InputCheckbox } from './InputCheckbox'
diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss
new file mode 100644
index 000000000..ac159ac4b
--- /dev/null
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss
@@ -0,0 +1,140 @@
+@use '../../../../../styles/typography';
+@import '../../../../../styles/includes';
+
+.select {
+ margin: 0 -10px;
+ background: none transparent;
+
+ border-radius: $sp-1;
+}
+
+.select .sel {
+ display: block;
+
+ &:global(__value-container) {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ flex-wrap: wrap;
+ position: relative;
+ margin: 0 10px;
+ padding: 0;
+ gap: 8px;
+ overflow: auto;
+ }
+
+ &:global(__indicators) {
+ display: flex;
+ }
+
+ &:global(__indicator-separator) {
+ display: none;
+ }
+
+ &:global(__placeholder) {
+ position: absolute;
+ font-size: 14px;
+ line-height: 16px;
+ color: $black-60;
+ font-weight: normal;
+ margin: 0;
+ }
+
+ &:global(__control) {
+ border: 0 none;
+ box-shadow: none;
+
+ align-items: center;
+ cursor: default;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ min-height: 0;
+ outline: 0!important;
+ position: relative;
+ transition: all 100ms;
+ background: none;
+ border-radius: 4px;
+ height: $sp-55;
+ margin-top: 2px;
+ }
+
+ &:global(__input-container) {
+ font-size: 14px;
+ line-height: 16px;
+ color: $black-60;
+ display: inline-grid;
+ flex: 1 1 auto;
+ margin: 0;
+ grid-template-columns: 0 min-content;
+ padding: 0;
+ visibility: visible;
+ order: 999;
+ > 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..7f7689a96
--- /dev/null
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx
@@ -0,0 +1,156 @@
+import {
+ ChangeEvent,
+ FC,
+ FocusEvent,
+ MutableRefObject,
+ ReactNode,
+ useMemo,
+ useRef,
+} from 'react'
+import { find } from 'lodash'
+import CreatableSelect from 'react-select/creatable'
+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
+ readonly creatable?: boolean
+ readonly createLabel?: (inputValue: string) => string
+ readonly onCreateOption?: (inputValue: string) => void
+ readonly onBlur?: (event: FocusEvent) => void
+ readonly openMenuOnClick?: boolean
+ readonly openMenuOnFocus?: boolean
+ readonly filterOption?: (option: InputSelectOption, value: string) => boolean
+}
+
+/**
+ * Finds the nearest ancestor