Skip to content

Commit 9a5b5de

Browse files
committed
Implement generic multiselect input & skill select input
1 parent 16f20af commit 9a5b5de

File tree

13 files changed

+339
-3
lines changed

13 files changed

+339
-3
lines changed

.storybook/main.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@ const config: StorybookConfig = {
3232
};
3333
}
3434

35-
return config;
35+
return {
36+
...config,
37+
plugins: config.plugins?.filter(plugin => {
38+
if (plugin.constructor.name === 'ESLintWebpackPlugin') {
39+
return false
40+
}
41+
return true
42+
}),
43+
};
3644
}
3745
};
3846
export default config;

.vscode/components.code-snippets

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"interface ${1:ComponentName}Props {",
2727
"}",
2828
"",
29-
"const ${1:ComponentName}: FC<${1:ComponentName}Props> = (props: ${1:ComponentName}Props) => {",
29+
"const ${1:ComponentName}: FC<${1:ComponentName}Props> = props => {",
3030
"",
3131
" return (",
3232
" <div className={styles['wrap']}>",

src/.eslintrc.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ module.exports = {
7171
parameter: true,
7272
memberVariableDeclaration: true,
7373
callSignature: true,
74-
variableDeclaration: true,
74+
variableDeclaration: false,
7575
arrayDestructuring: false,
7676
objectDestructuring: true,
7777
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ChangeEvent, FC } from 'react'
2+
import { noop } from 'lodash'
3+
4+
import { InputMultiselect } from '~/libs/ui'
5+
6+
import { autoCompleteSkills } from '../../services/emsi-skills'
7+
8+
interface Option {
9+
label: string
10+
value: string
11+
}
12+
13+
const fetchSkills = (queryTerm: string): Promise<Option[]> => (
14+
autoCompleteSkills(queryTerm)
15+
.then(skills => (
16+
skills.map(skill => ({
17+
label: skill.name,
18+
value: skill.emsiId,
19+
}))
20+
))
21+
)
22+
23+
interface InputSkillSelectorProps {
24+
readonly onChange?: (event: ChangeEvent<HTMLInputElement>) => void
25+
}
26+
27+
const InputSkillSelector: FC<InputSkillSelectorProps> = props => (
28+
<InputMultiselect
29+
label='Select Skills'
30+
placeholder='Type to add a skill...'
31+
onFetchOptions={fetchSkills}
32+
name='skills'
33+
onChange={props.onChange ?? noop}
34+
/>
35+
)
36+
37+
export default InputSkillSelector
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as InputSkillSelector } from './InputSkillSelector'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { EnvironmentConfig } from '~/config'
2+
import { xhrGetAsync } from '~/libs/core'
3+
4+
import Skill from './skill.model'
5+
6+
export async function autoCompleteSkills(queryTerm: string): Promise<Skill[]> {
7+
return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`)
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './skill.model'
2+
export * from './emsi-skills.service'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export default interface Skill {
2+
name: string;
3+
emsiId: string;
4+
}

src/libs/ui/lib/components/form/form-groups/form-input/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './input-image-picker'
33
export * from './form-input-autcomplete-option.enum'
44
export * from './input-rating'
55
export * from './input-select'
6+
export * from './input-multiselect'
67
export * from './input-text'
78
export * from './input-textarea'
89
export { inputOptional, InputWrapper } from './input-wrapper'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
@import '../../../../../styles/includes';
2+
3+
.multiselect .ms {
4+
display: block;
5+
6+
&:global(__value-container) {
7+
display: flex;
8+
align-items: center;
9+
flex: 1;
10+
flex-wrap: wrap;
11+
position: relative;
12+
overflow: hidden;
13+
margin: 0 10px;
14+
padding: 0;
15+
gap: 8px;
16+
}
17+
18+
&:global(__indicators) {
19+
display: none;
20+
}
21+
22+
&:global(__placeholder) {
23+
position: absolute;
24+
font-size: 14px;
25+
line-height: 16px;
26+
color: $black-60;
27+
}
28+
29+
&:global(__control) {
30+
border: 0 none;
31+
box-shadow: none;
32+
33+
align-items: center;
34+
cursor: default;
35+
display: flex;
36+
flex-wrap: wrap;
37+
justify-content: space-between;
38+
min-height: 0;
39+
outline: 0!important;
40+
position: relative;
41+
transition: all 100ms;
42+
background: none;
43+
border-radius: 4px;
44+
}
45+
46+
&:global(__input-container) {
47+
font-size: 14px;
48+
line-height: 16px;
49+
color: $black-60;
50+
display: inline-grid;
51+
flex: 1 1 auto;
52+
margin: 0;
53+
grid-template-columns: 0 min-content;
54+
padding: 0;
55+
visibility: visible;
56+
}
57+
58+
&:global(__multi-value) {
59+
margin: 0;
60+
background: $teal-140;
61+
color: $tc-white;
62+
border-radius: 4px;
63+
64+
&:global(__remove) {
65+
cursor: pointer;
66+
border: 0 none;
67+
background: none;
68+
outline: none;
69+
appearance: none;
70+
display: flex;
71+
align-items: center;
72+
justify-content: center;
73+
border-radius: 4px;
74+
padding: 2px;
75+
margin: 2px 6px 2px 0;
76+
77+
svg {
78+
display: block;
79+
width: 16px;
80+
height: 16px;
81+
}
82+
}
83+
&:global(__label) {
84+
color: $tc-white;
85+
overflow: hidden;
86+
text-overflow: ellipsis;
87+
white-space: nowrap;
88+
border-radius: 4px;
89+
padding: 4px;
90+
padding-left: 8px;
91+
padding-right: 2px;
92+
font-size: 14px;
93+
line-height: 16px;
94+
letter-spacing: 0.5px;
95+
font-family: $font-roboto;
96+
font-weight: $font-weight-medium;
97+
}
98+
}
99+
100+
&:global(__menu) {
101+
top: 100%;
102+
position: absolute;
103+
width: 100%;
104+
z-index: 1;
105+
background-color: $tc-white;
106+
border-radius: 4px;
107+
box-shadow: 0px 4px 4px 0px rgba(0,0,0,0.25);
108+
margin-bottom: 5px;
109+
margin-top: 5px;
110+
border: 1px solid $black-40;
111+
&:global(-list) {
112+
max-height: 300px;
113+
overflow-y: auto;
114+
position: relative;
115+
-webkit-overflow-scrolling: touch;
116+
padding: 8px 0;
117+
}
118+
&:global(-notice) {
119+
text-align: center;
120+
color: #999;
121+
padding: 8px 12px;
122+
}
123+
}
124+
&:global(__option) {
125+
cursor: default;
126+
display: block;
127+
width: 100%;
128+
user-select: none;
129+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
130+
background-color: transparent;
131+
color: $black-100;
132+
padding: 8px 16px;
133+
134+
font-size: 16px;
135+
line-height: 24px;
136+
137+
&:global(--is-focused) {
138+
background-color: $turq-160;
139+
color: $tc-white;
140+
}
141+
}
142+
}
143+
144+
.multiselect {
145+
margin: 8px -10px 0;
146+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* eslint-disable no-underscore-dangle */
2+
/* eslint-disable camelcase */
3+
import { Meta, StoryObj } from '@storybook/react'
4+
5+
import { InputMultiselect } from '.'
6+
7+
const meta: Meta<typeof InputMultiselect> = {
8+
argTypes: {
9+
},
10+
component: InputMultiselect,
11+
excludeStories: /.*Decorator$/,
12+
// tags: ['autodocs'],
13+
title: 'Forms/InputMultiselect',
14+
}
15+
16+
export default meta
17+
18+
type Story = StoryObj<typeof InputMultiselect>;
19+
20+
export const Basic: Story = {
21+
args: {
22+
onChange: d => console.log(d),
23+
onFetchOptions: d => Promise.resolve(d ? [
24+
{ label: 'Option 1', value: '1' },
25+
{ label: 'Option 2', value: '2' },
26+
{ label: 'Option 3', value: '3' },
27+
{ label: 'Option 4', value: '4' },
28+
{ label: 'Option 5', value: '5' },
29+
{ label: 'Option 6', value: '6' },
30+
{ label: 'Option 7', value: '7' },
31+
{ label: 'Option 8', value: '8' },
32+
{ label: 'Option 9', value: '9' },
33+
{ label: 'Option 10', value: '10' },
34+
{ label: 'Option 11', value: '11' },
35+
{ label: 'Option 12', value: '12' },
36+
{ label: 'Option 13', value: '13' },
37+
{ label: 'Option 14', value: '14' },
38+
{ label: 'Option 15', value: '15' },
39+
{ label: 'Option 16', value: '16' },
40+
{ label: 'Option 17', value: '17' },
41+
{ label: 'Option 18', value: '18' },
42+
{ label: 'Option 19', value: '19' },
43+
] : []),
44+
},
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
ChangeEvent,
3+
FC,
4+
ReactNode,
5+
} from 'react'
6+
import { noop } from 'lodash'
7+
import { components } from 'react-select'
8+
import AsyncSelect from 'react-select/async'
9+
10+
import { InputWrapper } from '../input-wrapper'
11+
import { IconSolid } from '../../../../svgs'
12+
13+
import styles from './InputMultiselect.module.scss'
14+
15+
export interface InputMultiselectOption {
16+
label?: ReactNode
17+
value: string
18+
}
19+
20+
interface InputMultiselectProps {
21+
readonly dirty?: boolean
22+
readonly disabled?: boolean
23+
readonly error?: string
24+
readonly hideInlineErrors?: boolean
25+
readonly hint?: string
26+
readonly label?: string
27+
readonly name: string
28+
readonly onChange: (event: ChangeEvent<HTMLInputElement>) => void
29+
readonly options?: ReadonlyArray<InputMultiselectOption>
30+
readonly placeholder?: string
31+
readonly tabIndex?: number
32+
readonly value?: string
33+
readonly onFetchOptions?: (query: string) => Promise<InputMultiselectOption[]>
34+
}
35+
36+
const MultiValueRemove: FC = (props: any) => (
37+
<components.MultiValueRemove {...props}>
38+
<IconSolid.XCircleIcon />
39+
</components.MultiValueRemove>
40+
)
41+
42+
const InputMultiselect: FC<InputMultiselectProps> = (props: InputMultiselectProps) => {
43+
44+
function handleOnChange(options: readonly InputMultiselectOption[]): void {
45+
props.onChange({
46+
target: { value: options },
47+
} as unknown as ChangeEvent<HTMLInputElement>)
48+
}
49+
50+
return (
51+
<InputWrapper
52+
{...props}
53+
dirty={!!props.dirty}
54+
disabled={!!props.disabled}
55+
label={(props.label || props.name) ?? 'Select Option'}
56+
hideInlineErrors={props.hideInlineErrors}
57+
type='text'
58+
>
59+
<AsyncSelect
60+
className={styles.multiselect}
61+
classNamePrefix={styles.ms}
62+
unstyled
63+
isMulti
64+
cacheOptions
65+
autoFocus
66+
defaultOptions
67+
placeholder={props.placeholder}
68+
loadOptions={props.onFetchOptions}
69+
name={props.name}
70+
onChange={handleOnChange}
71+
onBlur={noop}
72+
blurInputOnSelect={false}
73+
components={{
74+
// MultiValueLabel: () =>
75+
MultiValueRemove,
76+
}}
77+
/>
78+
</InputWrapper>
79+
)
80+
}
81+
82+
export default InputMultiselect
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as InputMultiselect } from './InputMultiselect'
2+
export { type InputMultiselectOption } from './InputMultiselect'

0 commit comments

Comments
 (0)