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 ( +
+
+ ) +} + +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 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' && ( +