diff --git a/.storybook/main.ts b/.storybook/main.ts index 26a4cb686..e5f14586f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -32,7 +32,15 @@ const config: StorybookConfig = { }; } - return config; + return { + ...config, + plugins: config.plugins?.filter(plugin => { + if (plugin.constructor.name === 'ESLintWebpackPlugin') { + return false + } + return true + }), + }; } }; export default config; diff --git a/.vscode/components.code-snippets b/.vscode/components.code-snippets index da4851e5f..85c9b54fa 100644 --- a/.vscode/components.code-snippets +++ b/.vscode/components.code-snippets @@ -26,7 +26,7 @@ "interface ${1:ComponentName}Props {", "}", "", - "const ${1:ComponentName}: FC<${1:ComponentName}Props> = (props: ${1:ComponentName}Props) => {", + "const ${1:ComponentName}: FC<${1:ComponentName}Props> = props => {", "", " return (", "
", diff --git a/src/.eslintrc.js b/src/.eslintrc.js index c2a131980..3a4ea5127 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -71,7 +71,7 @@ module.exports = { parameter: true, memberVariableDeclaration: true, callSignature: true, - variableDeclaration: true, + variableDeclaration: false, arrayDestructuring: false, objectDestructuring: true, }, diff --git a/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx new file mode 100644 index 000000000..78f0800ed --- /dev/null +++ b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx @@ -0,0 +1,37 @@ +import { ChangeEvent, FC } from 'react' +import { noop } from 'lodash' + +import { InputMultiselect } from '~/libs/ui' + +import { autoCompleteSkills } from '../../services/emsi-skills' + +interface Option { + label: string + value: string +} + +const fetchSkills = (queryTerm: string): Promise => ( + autoCompleteSkills(queryTerm) + .then(skills => ( + skills.map(skill => ({ + label: skill.name, + value: skill.emsiId, + })) + )) +) + +interface InputSkillSelectorProps { + readonly onChange?: (event: ChangeEvent) => void +} + +const InputSkillSelector: FC = props => ( + +) + +export default InputSkillSelector diff --git a/src/libs/shared/lib/components/input-skill-selector/index.ts b/src/libs/shared/lib/components/input-skill-selector/index.ts new file mode 100644 index 000000000..40ffd6dbd --- /dev/null +++ b/src/libs/shared/lib/components/input-skill-selector/index.ts @@ -0,0 +1 @@ +export { default as InputSkillSelector } from './InputSkillSelector' diff --git a/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts new file mode 100644 index 000000000..0adc93c5d --- /dev/null +++ b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts @@ -0,0 +1,8 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import Skill from './skill.model' + +export async function autoCompleteSkills(queryTerm: string): Promise { + return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`) +} diff --git a/src/libs/shared/lib/services/emsi-skills/index.ts b/src/libs/shared/lib/services/emsi-skills/index.ts new file mode 100644 index 000000000..214766f94 --- /dev/null +++ b/src/libs/shared/lib/services/emsi-skills/index.ts @@ -0,0 +1,2 @@ +export * from './skill.model' +export * from './emsi-skills.service' diff --git a/src/libs/shared/lib/services/emsi-skills/skill.model.ts b/src/libs/shared/lib/services/emsi-skills/skill.model.ts new file mode 100644 index 000000000..e9c21d4fc --- /dev/null +++ b/src/libs/shared/lib/services/emsi-skills/skill.model.ts @@ -0,0 +1,4 @@ +export default interface Skill { + name: string; + emsiId: string; +} diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/index.ts b/src/libs/ui/lib/components/form/form-groups/form-input/index.ts index 3afe01198..bb617f0c3 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/index.ts +++ b/src/libs/ui/lib/components/form/form-groups/form-input/index.ts @@ -3,6 +3,7 @@ export * from './input-image-picker' export * from './form-input-autcomplete-option.enum' export * from './input-rating' export * from './input-select' +export * from './input-multiselect' export * from './input-text' export * from './input-textarea' export { inputOptional, InputWrapper } from './input-wrapper' diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.module.scss new file mode 100644 index 000000000..d1b667f2c --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.module.scss @@ -0,0 +1,146 @@ +@import '../../../../../styles/includes'; + +.multiselect .ms { + display: block; + + &:global(__value-container) { + display: flex; + align-items: center; + flex: 1; + flex-wrap: wrap; + position: relative; + overflow: hidden; + margin: 0 10px; + padding: 0; + gap: 8px; + } + + &:global(__indicators) { + display: none; + } + + &:global(__placeholder) { + position: absolute; + font-size: 14px; + line-height: 16px; + color: $black-60; + } + + &:global(__control) { + border: 0 none; + box-shadow: none; + + align-items: center; + cursor: default; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + min-height: 0; + outline: 0!important; + position: relative; + transition: all 100ms; + background: none; + border-radius: 4px; + } + + &: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; + } + + &:global(__multi-value) { + margin: 0; + background: $teal-140; + color: $tc-white; + border-radius: 4px; + + &:global(__remove) { + cursor: pointer; + border: 0 none; + background: none; + outline: none; + appearance: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 2px; + margin: 2px 6px 2px 0; + + svg { + display: block; + width: 16px; + height: 16px; + } + } + &:global(__label) { + color: $tc-white; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 4px; + padding: 4px; + padding-left: 8px; + padding-right: 2px; + font-size: 14px; + line-height: 16px; + letter-spacing: 0.5px; + font-family: $font-roboto; + font-weight: $font-weight-medium; + } + } + + &:global(__menu) { + top: 100%; + position: absolute; + width: 100%; + z-index: 1; + 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(--is-focused) { + background-color: $turq-160; + color: $tc-white; + } + } +} + +.multiselect { + margin: 8px -10px 0; +} diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.stories.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.stories.tsx new file mode 100644 index 000000000..5bfa617c9 --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.stories.tsx @@ -0,0 +1,45 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable camelcase */ +import { Meta, StoryObj } from '@storybook/react' + +import { InputMultiselect } from '.' + +const meta: Meta = { + argTypes: { + }, + component: InputMultiselect, + excludeStories: /.*Decorator$/, + // tags: ['autodocs'], + title: 'Forms/InputMultiselect', +} + +export default meta + +type Story = StoryObj; + +export const Basic: Story = { + args: { + onChange: d => console.log(d), + onFetchOptions: d => Promise.resolve(d ? [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + { label: 'Option 4', value: '4' }, + { label: 'Option 5', value: '5' }, + { label: 'Option 6', value: '6' }, + { label: 'Option 7', value: '7' }, + { label: 'Option 8', value: '8' }, + { label: 'Option 9', value: '9' }, + { label: 'Option 10', value: '10' }, + { label: 'Option 11', value: '11' }, + { label: 'Option 12', value: '12' }, + { label: 'Option 13', value: '13' }, + { label: 'Option 14', value: '14' }, + { label: 'Option 15', value: '15' }, + { label: 'Option 16', value: '16' }, + { label: 'Option 17', value: '17' }, + { label: 'Option 18', value: '18' }, + { label: 'Option 19', value: '19' }, + ] : []), + }, +} diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx new file mode 100644 index 000000000..33bd0405b --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx @@ -0,0 +1,82 @@ +import { + ChangeEvent, + FC, + ReactNode, +} from 'react' +import { noop } from 'lodash' +import { components } from 'react-select' +import AsyncSelect from 'react-select/async' + +import { InputWrapper } from '../input-wrapper' +import { IconSolid } from '../../../../svgs' + +import styles from './InputMultiselect.module.scss' + +export interface InputMultiselectOption { + label?: ReactNode + value: string +} + +interface InputMultiselectProps { + 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?: ReadonlyArray + readonly placeholder?: string + readonly tabIndex?: number + readonly value?: string + readonly onFetchOptions?: (query: string) => Promise +} + +const MultiValueRemove: FC = (props: any) => ( + + + +) + +const InputMultiselect: FC = (props: InputMultiselectProps) => { + + function handleOnChange(options: readonly InputMultiselectOption[]): void { + props.onChange({ + target: { value: options }, + } as unknown as ChangeEvent) + } + + return ( + + + MultiValueRemove, + }} + /> + + ) +} + +export default InputMultiselect diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/index.ts b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/index.ts new file mode 100644 index 000000000..38d4054f9 --- /dev/null +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/index.ts @@ -0,0 +1,2 @@ +export { default as InputMultiselect } from './InputMultiselect' +export { type InputMultiselectOption } from './InputMultiselect'