@archbase/components 4.0.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ import {
2
+ CheckIcon,
3
+ CloseButton,
4
+ Combobox,
5
+ ComboboxDropdown,
6
+ ComboboxTarget,
7
+ FloatingPosition,
8
+ Group,
9
+ Image,
10
+ MantineSize,
11
+ MantineStyleProp,
12
+ OptionsFilter,
13
+ Pill,
14
+ PillsInput,
15
+ ScrollArea,
16
+ Text,
17
+ useCombobox,
18
+ } from '@mantine/core';
19
+ import { uniqueId } from 'lodash';
20
+ import React, {
21
+ CSSProperties,
22
+ FocusEventHandler,
23
+ forwardRef,
24
+ ReactNode,
25
+ useCallback,
26
+ useEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState
30
+ } from 'react';
31
+ import { ArchbaseDataSource, DataSourceEvent, DataSourceEventNames } from '@archbase/data';
32
+ import { useArchbaseDidMount, useArchbaseDidUpdate, useArchbaseWillUnmount } from '@archbase/core';
33
+
34
+ export interface ArchbaseMultiSelectProps<T, ID, O> {
35
+ /** Permite ou não desselecionar um item */
36
+ allowDeselect?: boolean;
37
+ /** Indicador se permite limpar o select */
38
+ clearable?: boolean;
39
+ /** Fonte de dados onde será atribuido o item selecionado */
40
+ dataSource?: ArchbaseDataSource<T, ID>;
41
+ /** Campo onde deverá ser atribuido o item selecionado na fonte de dados */
42
+ dataField?: string;
43
+ /** Indicador se o select está desabilitado */
44
+ disabled?: boolean;
45
+ /** Indicador se o select é somente leitura. Obs: usado em conjunto com o status da fonte de dados */
46
+ readOnly?: boolean;
47
+ /** Texto explicativo do select */
48
+ placeholder?: string;
49
+ /** Título do select */
50
+ label?: string;
51
+ /** Descrição do select */
52
+ description?: string;
53
+ /** Último erro ocorrido no select */
54
+ error?: string;
55
+ /** Permite pesquisar no select */
56
+ searchable?: boolean;
57
+ /** Icon a esquerda do select */
58
+ icon?: ReactNode;
59
+ /** Largura do icone a esquerda do select */
60
+ iconWidth?: MantineSize;
61
+ /** Valor de entrada controlado */
62
+ value?: any[];
63
+ /** Valor padrão de entrada não controlado */
64
+ defaultValue?: any[];
65
+ /** Função com base em quais itens no menu suspenso são filtrados */
66
+ filter?: OptionsFilter;
67
+ /** Estilo do select */
68
+ style?: MantineStyleProp;
69
+ /** Tamanho do campo */
70
+ size?: MantineSize;
71
+ /** Largura do select */
72
+ width?: number | string | undefined;
73
+ /** Estado aberto do menu suspenso inicial */
74
+ initiallyOpened?: boolean;
75
+ /** Alterar renderizador de item */
76
+ itemComponent?: React.FC<any>;
77
+ /** Alterar renderizador de item selecionado */
78
+ selectedItemComponent?: React.FC<any>;
79
+ /** Chamado quando o menu suspenso é aberto */
80
+ onDropdownOpen?(): void;
81
+ /** Chamado quando o menu suspenso é aberto */
82
+ onDropdownClose?(): void;
83
+ /** Limite a quantidade de itens exibidos por vez para seleção pesquisável */
84
+ limit?: number;
85
+ /** Rótulo nada encontrado */
86
+ nothingFound?: React.ReactNode;
87
+ /** Índice z dropdown */
88
+ zIndex?: React.CSSProperties['zIndex'];
89
+ /** Comportamento de posicionamento dropdown */
90
+ dropdownPosition?: FloatingPosition;
91
+ /** Evento quando os valores selecionados são alterados */
92
+ onChangeValues?: (values: O[]) => void;
93
+ /** Evento quando o foco sai do select */
94
+ onFocusExit?: FocusEventHandler<T> | undefined;
95
+ /** Evento quando o select recebe o foco */
96
+ onFocusEnter?: FocusEventHandler<T> | undefined;
97
+ /** Opções de seleção iniciais */
98
+ initialOptions?: O[];
99
+ /** Function que retorna o label de uma opção */
100
+ getOptionLabel: (option: O) => string;
101
+ /** Function que retorna o valor de uma opção */
102
+ getOptionValue: (option: O) => any;
103
+ /** Function que retorna a imagem de uma opção */
104
+ getOptionImage?: (option: O) => any | undefined | null;
105
+ /** Indica se o select tem o preenchimento obrigatório */
106
+ required?: boolean;
107
+ /** Chamado sempre que o valor da pesquisa muda */
108
+ onSearchChange?(query: string): void;
109
+ /** Converte o valor antes de atribuir ao field do registro atual no datasource */
110
+ converter?: (value: O) => any;
111
+ /** Function que busca o valor original antes de converter pelo valor de retorno do converter */
112
+ getConvertedOption?: (value: any) => Promise<O>;
113
+ /** Coleção de opções do select */
114
+ options?: ReadonlyArray<any> | ArchbaseDataSource<any, any>;
115
+ /** Campo do label quando options é um ArchbaseDataSource */
116
+ optionsLabelField?: string;
117
+ /** Coleção de ReactNode que representam as opções do select */
118
+ children?: ReactNode | ReactNode[];
119
+ /** Máxima altura do dropdown */
120
+ maxDropdownHeight?: number;
121
+ /** Função de ordenação customizada para os valores selecionados */
122
+ sortSelectedValues?: (a: O, b: O) => number;
123
+ }
124
+
125
+ function buildOptions<O>(
126
+ options?: ReadonlyArray<any> | ArchbaseDataSource<any, any>,
127
+ initialOptions?: O[],
128
+ children?: ReactNode | ReactNode[] | undefined,
129
+ getOptionLabel?: (option: O) => string,
130
+ getOptionValue?: (option: O) => any,
131
+ getOptionImage?: (option: O) => any | undefined | null,
132
+ optionsLabelField?: string
133
+ ): any {
134
+ if (!initialOptions && !children && !options) {
135
+ return [];
136
+ }
137
+
138
+ // Se options é um ArchbaseDataSource
139
+ if (
140
+ options &&
141
+ options instanceof ArchbaseDataSource &&
142
+ getOptionLabel &&
143
+ getOptionValue &&
144
+ optionsLabelField
145
+ ) {
146
+ const ds = options as ArchbaseDataSource<any, any>;
147
+ ds.first();
148
+ const result: any[] = [];
149
+ while (!ds.isEOF()) {
150
+ const record = ds.getCurrentRecord();
151
+ result.push({
152
+ label: ds.getFieldValue(optionsLabelField),
153
+ value: getOptionValue(record),
154
+ image: getOptionImage ? getOptionImage(record) : undefined,
155
+ origin: record,
156
+ key: uniqueId('select'),
157
+ });
158
+ ds.next();
159
+ }
160
+ ds.first();
161
+ return result;
162
+ }
163
+
164
+ // Se options é um array
165
+ if (options && Array.isArray(options)) {
166
+ return options;
167
+ }
168
+
169
+ // Se children foi passado
170
+ if (children) {
171
+ return React.Children.toArray(children).map((item: any) => {
172
+ const { label, value, origin, ...others } = item.props;
173
+ return {
174
+ label: label,
175
+ value: value,
176
+ origin: origin !== undefined ? origin : value,
177
+ key: uniqueId('select'),
178
+ ...others
179
+ };
180
+ });
181
+ }
182
+
183
+ // Se initialOptions foi passado
184
+ if (getOptionImage) {
185
+ return initialOptions!.map((item: O) => {
186
+ return {
187
+ label: getOptionLabel!(item),
188
+ value: getOptionValue!(item),
189
+ image: getOptionImage(item),
190
+ origin: item,
191
+ key: uniqueId('select'),
192
+ };
193
+ });
194
+ }
195
+
196
+ return initialOptions!.map((item: O) => {
197
+ return {
198
+ label: getOptionLabel!(item),
199
+ value: getOptionValue!(item),
200
+ origin: item,
201
+ key: uniqueId('select'),
202
+ };
203
+ });
204
+ }
205
+
206
+ const areArraysEqual = (arr1: any[], arr2: any[]) => {
207
+ if (arr1.length !== arr2.length) return false;
208
+ return arr1.every((item, index) => item === arr2[index]);
209
+ };
210
+
211
+ export const SelectItem = ({ image, label, description, values, ...others }) => (
212
+ <div {...others}>
213
+ <Group style={{ flexWrap: "nowrap" }}>
214
+ {image && <Image w={50} src={image} />}
215
+ <div>
216
+ <Text size="sm">{label}</Text>
217
+ {description && (
218
+ <Text size="xs" opacity={0.65}>
219
+ {description}
220
+ </Text>
221
+ )}
222
+ </div>
223
+ {values && values.includes(others.value) ? <CheckIcon size={12} /> : null}
224
+ </Group>
225
+ </div>
226
+ );
227
+
228
+ export const SelectedItem = ({ item, value, onRemove, label }) => (
229
+ <Pill
230
+ key={value}
231
+ withRemoveButton
232
+ onRemove={() => onRemove()}
233
+ >
234
+ {label}
235
+ </Pill>
236
+ );
237
+
238
+ export function ArchbaseMultiSelect<T, ID, O>({
239
+ allowDeselect = true,
240
+ clearable = true,
241
+ dataSource,
242
+ dataField,
243
+ disabled = false,
244
+ readOnly = false,
245
+ placeholder,
246
+ initialOptions = [],
247
+ searchable = true,
248
+ label,
249
+ description,
250
+ error,
251
+ icon,
252
+ iconWidth,
253
+ required,
254
+ getOptionLabel,
255
+ getOptionValue,
256
+ getOptionImage,
257
+ onFocusEnter,
258
+ onFocusExit,
259
+ onChangeValues,
260
+ value,
261
+ defaultValue,
262
+ filter,
263
+ size,
264
+ style,
265
+ width,
266
+ initiallyOpened,
267
+ itemComponent: ItemComponent = SelectItem,
268
+ selectedItemComponent: SelectedItemComponent = SelectedItem,
269
+ onDropdownOpen,
270
+ onDropdownClose,
271
+ limit,
272
+ nothingFound,
273
+ zIndex,
274
+ dropdownPosition,
275
+ onSearchChange,
276
+ converter,
277
+ getConvertedOption,
278
+ options,
279
+ optionsLabelField,
280
+ children,
281
+ maxDropdownHeight = 280,
282
+ sortSelectedValues
283
+ }: ArchbaseMultiSelectProps<T, ID, O>) {
284
+ const combobox = useCombobox({
285
+ onDropdownClose: () => {
286
+ combobox.resetSelectedOption();
287
+ if (onDropdownClose) {
288
+ onDropdownClose();
289
+ }
290
+ },
291
+ onDropdownOpen: () => {
292
+ if (onDropdownOpen) {
293
+ onDropdownOpen();
294
+ }
295
+ }
296
+ });
297
+
298
+ const [selectedValues, setSelectedValues] = useState<O[]>(defaultValue || []);
299
+ const [updateCounter, setUpdateCounter] = useState(0);
300
+ const [queryValue, setQueryValue] = useState<string>('');
301
+ const [internalError, setInternalError] = useState<string | undefined>(error);
302
+
303
+ const currentOptions: any[] = useMemo(() => {
304
+ return buildOptions<O>(
305
+ options,
306
+ initialOptions,
307
+ children,
308
+ getOptionLabel,
309
+ getOptionValue,
310
+ getOptionImage,
311
+ optionsLabelField
312
+ );
313
+ }, [
314
+ updateCounter,
315
+ options,
316
+ initialOptions,
317
+ children,
318
+ getOptionLabel,
319
+ getOptionValue,
320
+ getOptionImage,
321
+ optionsLabelField
322
+ ]);
323
+
324
+ const loadDataSourceFieldValue = async () => {
325
+ let initialValue: any = value;
326
+
327
+ if (dataSource && dataField) {
328
+ initialValue = dataSource.getFieldValue(dataField);
329
+ if (!initialValue) {
330
+ initialValue = [];
331
+ }
332
+ }
333
+ if (getConvertedOption && converter && initialValue && initialValue.length > 0) {
334
+ // Converte cada item do array
335
+ const convertedPromises = initialValue.map(item => getConvertedOption(item));
336
+ initialValue = await Promise.all(convertedPromises);
337
+ }
338
+ setSelectedValues(initialValue ?? []);
339
+ };
340
+
341
+ const fieldChangedListener = useCallback(() => {
342
+ loadDataSourceFieldValue();
343
+ }, []);
344
+
345
+ const dataSourceEvent = useCallback((event: DataSourceEvent<T>) => {
346
+ if (dataSource && dataField) {
347
+ if (
348
+ event.type === DataSourceEventNames.dataChanged ||
349
+ event.type === DataSourceEventNames.recordChanged ||
350
+ event.type === DataSourceEventNames.afterScroll ||
351
+ event.type === DataSourceEventNames.afterCancel
352
+ ) {
353
+ loadDataSourceFieldValue();
354
+ }
355
+
356
+ if (event.type === DataSourceEventNames.onFieldError && event.fieldName === dataField) {
357
+ setInternalError(event.error);
358
+ }
359
+ }
360
+ }, []);
361
+
362
+ const dataSourceOptionsEvent = useCallback((event: DataSourceEvent<T>) => {
363
+ if (event.type === DataSourceEventNames.dataChanged) {
364
+ setUpdateCounter((prevCounter) => prevCounter + 1);
365
+ }
366
+ }, []);
367
+
368
+ useArchbaseDidMount(() => {
369
+ loadDataSourceFieldValue();
370
+ if (dataSource && dataField) {
371
+ dataSource.addListener(dataSourceEvent);
372
+ dataSource.addFieldChangeListener(dataField, fieldChangedListener);
373
+ }
374
+
375
+ if (options && options instanceof ArchbaseDataSource) {
376
+ (options as ArchbaseDataSource<T, ID>).addListener(dataSourceOptionsEvent);
377
+ }
378
+ });
379
+
380
+ useArchbaseWillUnmount(() => {
381
+ if (dataSource && dataField) {
382
+ dataSource.removeListener(dataSourceEvent);
383
+ dataSource.removeFieldChangeListener(dataField, fieldChangedListener);
384
+ }
385
+
386
+ if (options && options instanceof ArchbaseDataSource) {
387
+ (options as ArchbaseDataSource<T, ID>).removeListener(dataSourceOptionsEvent);
388
+ }
389
+ });
390
+
391
+ useArchbaseDidUpdate(() => {
392
+ loadDataSourceFieldValue();
393
+ }, []);
394
+
395
+ useEffect(() => {
396
+ setInternalError(undefined);
397
+ }, [value, selectedValues, queryValue]);
398
+
399
+ useEffect(() => {
400
+ if (value !== undefined) {
401
+ setSelectedValues(value);
402
+ }
403
+ }, [value]);
404
+
405
+ const handleConverter = (value) => {
406
+ if (converter && value) {
407
+ return converter(value);
408
+ }
409
+ return value;
410
+ };
411
+
412
+ const handleValueRemove = (val: O) => {
413
+ setSelectedValues((current) => {
414
+ const updatedValues = current.filter((item) => getOptionValue(item) !== getOptionValue(val));
415
+ const convertedValues = updatedValues.map(updatedValue => handleConverter(updatedValue));
416
+
417
+ if (
418
+ dataSource &&
419
+ !dataSource.isBrowsing() &&
420
+ dataField &&
421
+ !areArraysEqual(
422
+ dataSource.getFieldValue(dataField) || [],
423
+ convertedValues
424
+ )
425
+ ) {
426
+ dataSource.setFieldValue(dataField, convertedValues);
427
+ }
428
+
429
+ if (onChangeValues) {
430
+ onChangeValues(updatedValues);
431
+ }
432
+
433
+ return updatedValues;
434
+ });
435
+ };
436
+
437
+ const handleChange = (optionValue) => {
438
+ // Encontra o objeto original da opção
439
+ const option = currentOptions.find(opt => opt.value === optionValue);
440
+ const value = option && option.origin ? option.origin : optionValue;
441
+
442
+ setSelectedValues((prevSelected) => {
443
+ const isSelected = prevSelected.some(
444
+ (item) => getOptionValue(item) === getOptionValue(value)
445
+ );
446
+
447
+ // Se o valor já está selecionado, o removemos. Caso contrário, adicionamos.
448
+ const updatedValues = isSelected
449
+ ? prevSelected.filter(
450
+ (item) => getOptionValue(item) !== getOptionValue(value)
451
+ )
452
+ : [...prevSelected, value];
453
+
454
+ const convertedValues = updatedValues.map(updatedValue => handleConverter(updatedValue));
455
+
456
+ if (
457
+ dataSource &&
458
+ !dataSource.isBrowsing() &&
459
+ dataField &&
460
+ !areArraysEqual(
461
+ dataSource.getFieldValue(dataField) || [],
462
+ convertedValues
463
+ )
464
+ ) {
465
+ dataSource.setFieldValue(dataField, convertedValues);
466
+ }
467
+
468
+ if (onChangeValues) {
469
+ onChangeValues(updatedValues);
470
+ }
471
+
472
+ return updatedValues;
473
+ });
474
+ };
475
+
476
+ const handleOnFocusExit = (event) => {
477
+ if (onFocusExit) {
478
+ onFocusExit(event);
479
+ }
480
+ };
481
+
482
+ const handleOnFocusEnter = (event) => {
483
+ if (onFocusEnter) {
484
+ onFocusEnter(event);
485
+ }
486
+ };
487
+
488
+ const handleSearchChange = (query: string) => {
489
+ setQueryValue(query);
490
+ if (onSearchChange) {
491
+ onSearchChange(query);
492
+ }
493
+ };
494
+
495
+ const isReadOnly = () => {
496
+ let isReadOnly = readOnly;
497
+ if (dataSource && !readOnly) {
498
+ isReadOnly = dataSource.isBrowsing();
499
+ }
500
+
501
+ return isReadOnly;
502
+ };
503
+
504
+ const selectedValuesLabels = selectedValues.map(selecteValue => getOptionLabel(selecteValue).toLowerCase());
505
+
506
+ const filteredOptions = currentOptions.filter((item) => {
507
+ const matchesSearch = !searchable || item.label.toLowerCase().includes(queryValue.toLowerCase().trim());
508
+ const notSelected = !selectedValuesLabels.includes(item.label.toLowerCase());
509
+ return matchesSearch && notSelected;
510
+ });
511
+
512
+ const sortedSelectedValues = sortSelectedValues
513
+ ? [...selectedValues].sort(sortSelectedValues)
514
+ : selectedValues;
515
+
516
+ const values = sortedSelectedValues.map((item) => (
517
+ <SelectedItemComponent
518
+ key={getOptionValue(item)}
519
+ item={item}
520
+ value={getOptionValue(item)}
521
+ onRemove={() => handleValueRemove(item)}
522
+ label={getOptionLabel(item)}
523
+ />
524
+ ));
525
+
526
+ const displayedOptions = limit ? filteredOptions.slice(0, limit) : filteredOptions;
527
+
528
+ return (
529
+ <Combobox
530
+ store={combobox}
531
+ withinPortal={true}
532
+ position={dropdownPosition}
533
+ zIndex={zIndex}
534
+ onOptionSubmit={(val) => {
535
+ handleChange(val);
536
+ setQueryValue('');
537
+ }}
538
+ >
539
+ <ComboboxTarget>
540
+ <PillsInput
541
+ disabled={disabled}
542
+ leftSection={icon}
543
+ leftSectionWidth={iconWidth}
544
+ label={label}
545
+ w={width}
546
+ description={description}
547
+ error={internalError}
548
+ required={required}
549
+ style={style}
550
+ onClick={() => combobox.openDropdown()}
551
+ rightSection={
552
+ <>
553
+ {selectedValues.length > 0 && clearable && !disabled && !isReadOnly() ? (
554
+ <CloseButton
555
+ size="sm"
556
+ onMouseDown={(event) => event.preventDefault()}
557
+ onClick={() => {
558
+ setQueryValue('');
559
+ setSelectedValues([]);
560
+ if (
561
+ dataSource &&
562
+ !dataSource.isBrowsing() &&
563
+ dataField
564
+ ) {
565
+ dataSource.setFieldValue(dataField, []);
566
+ }
567
+ if (onChangeValues) {
568
+ onChangeValues([]);
569
+ }
570
+ }}
571
+ aria-label="Clear value"
572
+ />
573
+ ) : (
574
+ <Combobox.Chevron />
575
+ )}
576
+ </>
577
+ }
578
+ rightSectionPointerEvents={selectedValues.length === 0 ? 'none' : 'all'}
579
+ >
580
+ <Pill.Group>
581
+ {values}
582
+ <PillsInput.Field
583
+ readOnly={isReadOnly()}
584
+ value={queryValue}
585
+ onChange={(event) => {
586
+ handleSearchChange(event.currentTarget.value);
587
+ if (filteredOptions.length > 0) {
588
+ combobox.openDropdown();
589
+ }
590
+ combobox.updateSelectedOptionIndex();
591
+ }}
592
+ onBlur={(event) => {
593
+ handleOnFocusExit(event);
594
+ }}
595
+ onFocus={(event) => handleOnFocusEnter(event)}
596
+ onClick={() => {
597
+ if (filteredOptions.length > 0) {
598
+ combobox.openDropdown();
599
+ }
600
+ }}
601
+ placeholder={placeholder}
602
+ onKeyDown={(event) => {
603
+ if (event.key === 'Backspace' && queryValue.length === 0) {
604
+ if (selectedValues.length > 0) {
605
+ handleValueRemove(selectedValues[selectedValues.length - 1]);
606
+ }
607
+ }
608
+ }}
609
+ />
610
+ </Pill.Group>
611
+ </PillsInput>
612
+ </ComboboxTarget>
613
+ <ComboboxDropdown>
614
+ <Combobox.Options>
615
+ <ScrollArea.Autosize mah={maxDropdownHeight} type="scroll">
616
+ {displayedOptions.length === 0 ? (
617
+ <Combobox.Empty>{nothingFound || 'Nada encontrado'}</Combobox.Empty>
618
+ ) : (
619
+ displayedOptions.map((option) => (
620
+ <Combobox.Option
621
+ value={option.value}
622
+ key={option.key}
623
+ >
624
+ {ItemComponent ? (
625
+ <ItemComponent
626
+ values={selectedValues.map(v => getOptionValue(v))}
627
+ {...option}
628
+ />
629
+ ) : (
630
+ option.label
631
+ )}
632
+ </Combobox.Option>
633
+ ))
634
+ )}
635
+ </ScrollArea.Autosize>
636
+ </Combobox.Options>
637
+ </ComboboxDropdown>
638
+ </Combobox>
639
+ );
640
+ }
641
+
642
+ ArchbaseMultiSelect.displayName = 'ArchbaseMultiSelect';