@buerokratt-ria/common-gui-components 0.0.60 → 0.0.61

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All changes to this project will be documented in this file.
5
5
  ## Template [MajorVersion.MediterraneanVersion.MinorVersion] - DD-MM-YYYY
6
6
 
7
7
 
8
+ ## [0.0.61] - 11.06.2026
9
+
10
+ - Removed redundant scroll from the History table
11
+ - Added Apply action to the History table header multi-option filter
12
+ - Made the History table header sticky
13
+
8
14
  ## [0.0.60] - 09.06.2026
9
15
 
10
16
  - History table: theme, follow up, quality sorting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buerokratt-ria/common-gui-components",
3
- "version": "0.0.60",
3
+ "version": "0.0.61",
4
4
  "description": "Common GUI components and pre defined templates.",
5
5
  "main": "index.ts",
6
6
  "author": "ExiRai",
@@ -69,9 +69,37 @@
69
69
  }
70
70
 
71
71
  .card-wrapper {
72
+ display: flex;
73
+ flex-direction: column;
72
74
  flex: 1;
73
- overflow: auto;
75
+ min-height: 0;
76
+ overflow: hidden;
74
77
  transition: flex 0.3s ease;
78
+
79
+ .card {
80
+ box-sizing: border-box;
81
+ display: flex;
82
+ flex: 1;
83
+ flex-direction: column;
84
+ max-height: none;
85
+ min-height: 0;
86
+ overflow: hidden;
87
+ }
88
+
89
+ .card__body {
90
+ display: flex;
91
+ flex: 1;
92
+ flex-direction: column;
93
+ min-height: 0;
94
+ overflow: hidden;
95
+ }
96
+
97
+ .data-table__scrollWrapper {
98
+ flex: 1;
99
+ max-height: none;
100
+ min-height: 0;
101
+ overflow: auto;
102
+ }
75
103
  }
76
104
 
77
105
  .drawer-container {
@@ -6,6 +6,7 @@ type HeaderComboboxBaseProps = {
6
6
  readonly label: string;
7
7
  readonly options?: ComponentProps<typeof FormCombobox>['options'];
8
8
  readonly isSearchEnabled?: ComponentProps<typeof FormCombobox>['isSearchEnabled'];
9
+ readonly allOptionValue?: ComponentProps<typeof FormCombobox>['allOptionValue'];
9
10
  };
10
11
 
11
12
  type HeaderComboboxSingleProps = HeaderComboboxBaseProps & {
@@ -18,6 +19,7 @@ type HeaderComboboxMultipleProps = HeaderComboboxBaseProps & {
18
19
  readonly multiple?: true;
19
20
  readonly value?: string[];
20
21
  readonly onChange: (value: string[]) => void;
22
+ readonly isApplyBtnVisible?: boolean;
21
23
  };
22
24
 
23
25
  type HeaderComboboxProps = HeaderComboboxSingleProps | HeaderComboboxMultipleProps;
@@ -27,6 +29,7 @@ const HeaderCombobox: FC<HeaderComboboxProps> = (props) => {
27
29
  label,
28
30
  options = [],
29
31
  isSearchEnabled = true,
32
+ allOptionValue,
30
33
  } = props;
31
34
  const sharedProps = {
32
35
  hideInputStyle: true,
@@ -37,6 +40,7 @@ const HeaderCombobox: FC<HeaderComboboxProps> = (props) => {
37
40
  options,
38
41
  isSearchEnabled,
39
42
  isMenuPortaled: true,
43
+ allOptionValue,
40
44
  };
41
45
 
42
46
  return (
@@ -58,6 +62,7 @@ const HeaderCombobox: FC<HeaderComboboxProps> = (props) => {
58
62
  multiple={true}
59
63
  value={props.value}
60
64
  onChange={props.onChange}
65
+ isApplyBtnVisible={props.isApplyBtnVisible}
61
66
  />
62
67
  )}
63
68
  </span>
@@ -0,0 +1 @@
1
+ export const ALL_COLUMNS_VALUE = '__all__';
@@ -16,9 +16,9 @@ import {
16
16
  Dialog,
17
17
  Drawer,
18
18
  FormCheckbox,
19
+ FormCombobox,
19
20
  FormDatepicker,
20
21
  FormInput,
21
- FormMultiselect,
22
22
  HistoricalChat,
23
23
  Icon,
24
24
  Tooltip,
@@ -40,6 +40,7 @@ import {StoreState} from "../../../store";
40
40
  import {saveFile} from "../../../services/file";
41
41
  import {ChatMetadataPanel, HeaderCombobox, QualitySettings, SelectedFilterTags} from './components';
42
42
  import { CharMeasurementType } from './types';
43
+ import { ALL_COLUMNS_VALUE } from './constants';
43
44
 
44
45
  type HistoryProps = {
45
46
  user: UserInfo | null;
@@ -162,7 +163,6 @@ const formatChatAnalysisCell = (
162
163
  return value.join(', ');
163
164
  };
164
165
 
165
- const ALL_COLUMNS_VALUE = '__all__';
166
166
  // Boolean -> truthy values before falsy
167
167
  // For other columns -> desc before asc - populated before empty
168
168
  const NON_EMPTY_FIRST_SORT_COLUMN_IDS = new Set([
@@ -436,7 +436,6 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
436
436
  const isChatAnalysisEnabled = useMemo(() => {
437
437
  return qualitySettingsConfigQuery.data?.chatAnalysisEnabled ?? false;
438
438
  }, [qualitySettingsConfigQuery.data]);
439
- console.log("IS CHAT ANALYSIS ENABLED", isChatAnalysisEnabled);
440
439
 
441
440
  const getAllEndedChats = useMutation({
442
441
  mutationFn: (data: {
@@ -580,21 +579,6 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
580
579
  return realSelectedColumns;
581
580
  };
582
581
 
583
- const normalizeSelectedColumns = (selection: string[]) => {
584
- const currentAllSelected = selectedColumns.includes(ALL_COLUMNS_VALUE) || areAllColumnsSelected(selectedColumns);
585
- const nextAllSelected = selection.includes(ALL_COLUMNS_VALUE);
586
-
587
- if (nextAllSelected && !currentAllSelected) {
588
- return [ALL_COLUMNS_VALUE, ...getAllColumnValues()];
589
- }
590
-
591
- if (!nextAllSelected && currentAllSelected) {
592
- return [];
593
- }
594
-
595
- return getUiSelectedColumns(selection);
596
- };
597
-
598
582
  const chatStatusChangeMutation = useMutation({
599
583
  mutationFn: async (data: { chatId: string | number; event: string }) => {
600
584
  const changeableTo = [
@@ -1268,10 +1252,12 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1268
1252
  label={t('chat.history.csaName')}
1269
1253
  options={customerSupportAgentsQuery.data ?? []}
1270
1254
  value={csaIdCodesFilter}
1255
+ allOptionValue={ALL_COLUMNS_VALUE}
1271
1256
  onChange={(value) => {
1272
1257
  const normalizedValue = normalizeCsaFilterValues(value);
1273
1258
  tableHeaderForm.setValue('csaIdCodesFilter', normalizedValue);
1274
1259
  }}
1260
+ isApplyBtnVisible
1275
1261
  />
1276
1262
  );
1277
1263
  },
@@ -1365,6 +1351,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1365
1351
  label={t('chat.history.rating') ?? ''}
1366
1352
  options={ratingOptions}
1367
1353
  value={feedbackRatings}
1354
+ allOptionValue={ALL_COLUMNS_VALUE}
1368
1355
  onChange={(value) => {
1369
1356
  setTableHeaderValue(
1370
1357
  'feedbackRatings',
@@ -1375,6 +1362,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1375
1362
  )
1376
1363
  );
1377
1364
  }}
1365
+ isApplyBtnVisible
1378
1366
  />
1379
1367
  );
1380
1368
  },
@@ -1399,6 +1387,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1399
1387
  label={t('global.status') ?? ''}
1400
1388
  options={statusOptions}
1401
1389
  value={status}
1390
+ allOptionValue={ALL_COLUMNS_VALUE}
1402
1391
  onChange={(value) => {
1403
1392
  setTableHeaderValue(
1404
1393
  'status',
@@ -1409,6 +1398,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1409
1398
  )
1410
1399
  );
1411
1400
  }}
1401
+ isApplyBtnVisible
1412
1402
  isSearchEnabled={true}
1413
1403
  />
1414
1404
  );
@@ -1447,12 +1437,14 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1447
1437
  label={t('chat.history.www') ?? ''}
1448
1438
  value={domains}
1449
1439
  options={domainOptions}
1440
+ allOptionValue={ALL_COLUMNS_VALUE}
1450
1441
  onChange={(value) => {
1451
1442
  setTableHeaderValue(
1452
1443
  'domains',
1453
1444
  normalizeAllOptionFilterValues(value, domains, currentDomains)
1454
1445
  );
1455
1446
  }}
1447
+ isApplyBtnVisible
1456
1448
  />
1457
1449
  );
1458
1450
  },
@@ -1472,6 +1464,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1472
1464
  label={t('chat.history.theme')}
1473
1465
  options={themeOptions}
1474
1466
  value={theme}
1467
+ allOptionValue={ALL_COLUMNS_VALUE}
1475
1468
  onChange={(value) => {
1476
1469
  setTableHeaderValue(
1477
1470
  'theme',
@@ -1482,6 +1475,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1482
1475
  )
1483
1476
  );
1484
1477
  }}
1478
+ isApplyBtnVisible
1485
1479
  />
1486
1480
  ),
1487
1481
  enableSorting: true,
@@ -1499,6 +1493,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1499
1493
  label={t('chat.history.responseQuality')}
1500
1494
  options={responseQualityOptions}
1501
1495
  value={responseQuality}
1496
+ allOptionValue={ALL_COLUMNS_VALUE}
1502
1497
  onChange={(value) => {
1503
1498
  setTableHeaderValue(
1504
1499
  'responseQuality',
@@ -1509,6 +1504,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1509
1504
  )
1510
1505
  );
1511
1506
  }}
1507
+ isApplyBtnVisible
1512
1508
  />
1513
1509
  ),
1514
1510
  enableSorting: true,
@@ -1526,6 +1522,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1526
1522
  label={t('chat.history.followUpStatus')}
1527
1523
  options={followUpStatusOptions}
1528
1524
  value={followUpStatus}
1525
+ allOptionValue={ALL_COLUMNS_VALUE}
1529
1526
  onChange={(value) => {
1530
1527
  setTableHeaderValue(
1531
1528
  'followUpStatus',
@@ -1536,6 +1533,7 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1536
1533
  )
1537
1534
  );
1538
1535
  }}
1536
+ isApplyBtnVisible
1539
1537
  />
1540
1538
  ),
1541
1539
  enableSorting: true,
@@ -2057,25 +2055,27 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
2057
2055
  </>
2058
2056
  )}
2059
2057
  <Track style={{width: '240px'}}>
2060
- <FormMultiselect
2058
+ <FormCombobox
2061
2059
  key={counterKey}
2062
2060
  name="visibleColumns"
2063
2061
  label={t('')}
2064
2062
  placeholder={t('chat.history.chosenColumn')}
2065
2063
  options={visibleColumnOptions}
2066
- selectedOptions={visibleColumnOptions.filter((o) =>
2067
- selectedColumns.includes(o.value)
2068
- )}
2064
+ value={selectedColumns}
2069
2065
  selectedOptionsCount={getRealSelectedColumns(selectedColumns).length}
2070
- onSelectionChange={(selection) => {
2071
- const columns = normalizeSelectedColumns(selection?.map((s) => s.value) ?? []);
2066
+ multiple={true}
2067
+ allOptionValue={ALL_COLUMNS_VALUE}
2068
+ direction="down"
2069
+ onChange={(selection) => {
2070
+ const columns = getUiSelectedColumns(selection);
2072
2071
  setSelectedColumns(columns);
2073
2072
  setCounterKey(prev => prev + 1);
2074
2073
  updatePagePreferences.mutate({
2075
2074
  page_results: pagination.pageSize,
2076
- selected_columns: getRealSelectedColumns(columns)
2075
+ selected_columns: getRealSelectedColumns(columns),
2077
2076
  })
2078
2077
  }}
2078
+ isApplyBtnVisible
2079
2079
  />
2080
2080
  </Track>
2081
2081
  </Track>
@@ -2105,12 +2105,13 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
2105
2105
  onRemove={removeSelectedFilterTag}
2106
2106
  onClearFiltersClick={onClearFilersClick}
2107
2107
  />
2108
- <div className="card-drawer-container" style={{height: '100%', overflow: 'auto', maxHeight: '60vh'}}>
2108
+ <div className="card-drawer-container">
2109
2109
  <div className="card-wrapper">
2110
2110
  <Card>
2111
2111
  <DataTable
2112
2112
  data={filteredEndedChatsList}
2113
2113
  sortable
2114
+ stickyHeader
2114
2115
  columns={getFilteredColumns()}
2115
2116
  selectedRow={(row) => row.original.id === selectedChat?.id}
2116
2117
  pagination={pagination}
@@ -2,6 +2,7 @@
2
2
  "global": {
3
3
  "email": "Email",
4
4
  "save": "Save",
5
+ "apply": "Apply",
5
6
  "add": "Add",
6
7
  "edit": "Edit",
7
8
  "delete": "Delete",
@@ -2,6 +2,7 @@
2
2
  "global": {
3
3
  "email": "Email",
4
4
  "save": "Salvesta",
5
+ "apply": "Rakenda",
5
6
  "add": "Lisa",
6
7
  "edit": "Muuda",
7
8
  "delete": "Kustuta",
@@ -30,6 +30,14 @@
30
30
  position: relative;
31
31
  }
32
32
 
33
+ &--sticky-header {
34
+ th {
35
+ background-color: get-color(white);
36
+ border-bottom-color: transparent;
37
+ box-shadow: inset 0 -1px 0 get-color(black-coral-10);
38
+ }
39
+ }
40
+
33
41
  td {
34
42
  padding: 12px 24px 12px 16px;
35
43
  border-bottom: 1px solid get-color(black-coral-2);
@@ -55,6 +55,7 @@ type DataTableProps = {
55
55
  pagesCount?: number;
56
56
  meta?: TableMeta<any>;
57
57
  selectedRow?: (row: Row<any>) => boolean;
58
+ stickyHeader?: boolean;
58
59
  totalCountLabel?: string | null;
59
60
  };
60
61
 
@@ -116,6 +117,7 @@ const DataTable: FC<DataTableProps> = ({
116
117
  pagesCount,
117
118
  meta,
118
119
  selectedRow,
120
+ stickyHeader,
119
121
  totalCountLabel,
120
122
  }) => {
121
123
  const id = useId();
@@ -189,68 +191,72 @@ const DataTable: FC<DataTableProps> = ({
189
191
  return (
190
192
  <>
191
193
  <div className="data-table__scrollWrapper">
192
- <table className="data-table">
194
+ <table className={clsx('data-table', stickyHeader && 'data-table--sticky-header')}>
193
195
  {!disableHead && (
194
196
  <thead>
195
197
  {table.getHeaderGroups().map((headerGroup) => (
196
198
  <tr key={headerGroup.id}>
197
- {headerGroup.headers.map((header) => (
198
- <th
199
- key={header.id}
200
- style={{
201
- width: header.column.columnDef.meta?.size,
202
- position: header.column.columnDef.meta?.sticky
203
- ? 'sticky'
204
- : undefined,
205
- left:
206
- header.column.columnDef.meta?.sticky === 'left'
207
- ? `${header.column.getAfter('left') * 0.675}px`
208
- : undefined,
209
- right:
210
- header.column.columnDef.meta?.sticky === 'right'
211
- ? `${header.column.getAfter('right') * 0.675}px`
212
- : undefined,
213
- zIndex: header.column.columnDef.meta?.sticky ? 1 : 0,
214
- }}
215
- >
216
- {header.isPlaceholder ? null : (
217
- <Track gap={8}>
218
- {sortable && header.column.getCanSort() && (
219
- <button
220
- onClick={header.column.getToggleSortingHandler()}
221
- >
222
- {{
223
- asc: (
224
- <Icon
225
- icon={<MdExpandMore fontSize={20} />}
226
- size="medium"
227
- />
228
- ),
229
- desc: (
199
+ {headerGroup.headers.map((header) => {
200
+ const stickyColumn = header.column.columnDef.meta?.sticky;
201
+
202
+ return (
203
+ <th
204
+ key={header.id}
205
+ style={{
206
+ width: header.column.columnDef.meta?.size,
207
+ position:
208
+ stickyHeader || stickyColumn ? 'sticky' : undefined,
209
+ top: stickyHeader ? 0 : undefined,
210
+ left:
211
+ stickyColumn === 'left'
212
+ ? `${header.column.getAfter('left') * 0.675}px`
213
+ : undefined,
214
+ right:
215
+ stickyColumn === 'right'
216
+ ? `${header.column.getAfter('right') * 0.675}px`
217
+ : undefined,
218
+ zIndex: stickyHeader ? (stickyColumn ? 3 : 1) : stickyColumn ? 1 : 0,
219
+ }}
220
+ >
221
+ {header.isPlaceholder ? null : (
222
+ <Track gap={8}>
223
+ {sortable && header.column.getCanSort() && (
224
+ <button
225
+ onClick={header.column.getToggleSortingHandler()}
226
+ >
227
+ {{
228
+ asc: (
229
+ <Icon
230
+ icon={<MdExpandMore fontSize={20} />}
231
+ size="medium"
232
+ />
233
+ ),
234
+ desc: (
235
+ <Icon
236
+ icon={<MdExpandLess fontSize={20} />}
237
+ size="medium"
238
+ />
239
+ ),
240
+ }[header.column.getIsSorted() as string] ?? (
230
241
  <Icon
231
- icon={<MdExpandLess fontSize={20} />}
242
+ icon={<MdUnfoldMore fontSize={22} />}
232
243
  size="medium"
233
244
  />
234
- ),
235
- }[header.column.getIsSorted() as string] ?? (
236
- <Icon
237
- icon={<MdUnfoldMore fontSize={22} />}
238
- size="medium"
239
- />
240
- )}
241
- </button>
242
- )}
243
- {flexRender(
244
- header.column.columnDef.header,
245
- header.getContext()
246
- )}
247
- {filterable && header.column.getCanFilter() && (
248
- <Filter column={header.column} table={table} />
249
- )}
250
- </Track>
251
- )}
252
- </th>
253
- ))}
245
+ )}
246
+ </button>
247
+ )}
248
+ {flexRender(
249
+ header.column.columnDef.header,
250
+ header.getContext()
251
+ )}
252
+ {filterable && header.column.getCanFilter() && (
253
+ <Filter column={header.column} table={table} />
254
+ )}
255
+ </Track>
256
+ )}
257
+ </th>
258
+ );
259
+ })}
254
260
  </tr>
255
261
  ))}
256
262
  </thead>
@@ -49,6 +49,10 @@
49
49
  display: block;
50
50
  }
51
51
 
52
+ + #{$self}__menu--combobox {
53
+ display: flex;
54
+ }
55
+
52
56
  +#{$self}__menu_up {
53
57
  display: block;
54
58
  }
@@ -124,7 +128,8 @@
124
128
  }
125
129
 
126
130
  #{$self}__search,
127
- #{$self}__options {
131
+ #{$self}__options,
132
+ #{$self}__actions {
128
133
  background-color: get-color(white);
129
134
  }
130
135
  }
@@ -152,15 +157,24 @@
152
157
  }
153
158
 
154
159
  &__menu--combobox {
160
+ flex-direction: column;
155
161
  top: auto;
156
162
  bottom: 100%;
163
+ max-height: none;
157
164
  overflow: hidden;
158
165
  margin-top: 0;
159
166
  margin-bottom: 3px;
160
167
  }
161
168
 
169
+ &__menu--down {
170
+ top: 100%;
171
+ bottom: auto;
172
+ margin-top: 3px;
173
+ margin-bottom: 0;
174
+ }
175
+
162
176
  &__menu--portal {
163
- display: block;
177
+ display: flex;
164
178
  position: fixed;
165
179
  top: 100%;
166
180
  bottom: auto;
@@ -171,13 +185,28 @@
171
185
  }
172
186
 
173
187
  &__options {
174
- max-height: 224px;
188
+ flex: 1 1 auto;
189
+ max-height: 220px;
190
+ min-height: 0;
175
191
  overflow: auto;
176
192
  list-style: none;
177
193
  margin: 0;
178
194
  padding: 0;
179
195
  }
180
196
 
197
+ &__actions {
198
+ display: flex;
199
+ flex: 0 0 auto;
200
+ justify-content: center;
201
+ padding: 12px 16px 16px;
202
+
203
+ > .btn.btn--s {
204
+ justify-content: center;
205
+ min-width: 106px;
206
+ padding: 4px 16px;
207
+ }
208
+ }
209
+
181
210
  &__search {
182
211
  padding: get-spacing(paldiski);
183
212
  position: relative;
@@ -238,7 +267,9 @@
238
267
  }
239
268
 
240
269
  &__option--combobox {
270
+ box-sizing: border-box;
241
271
  cursor: pointer;
272
+ min-height: 40px;
242
273
 
243
274
  &[aria-selected=true] {
244
275
  background-color: get-color(white);
@@ -15,7 +15,7 @@ import clsx from 'clsx';
15
15
  import { useTranslation } from 'react-i18next';
16
16
  import { MdArrowDropDown, MdExpandMore, MdSearch } from 'react-icons/md';
17
17
 
18
- import { Icon } from '../..';
18
+ import { Button, Icon } from '../..';
19
19
  import './FormCombobox.scss';
20
20
 
21
21
  type FormComboboxOption = {
@@ -35,6 +35,9 @@ type FormComboboxBaseProps = {
35
35
  readonly isSearchEnabled?: boolean;
36
36
  readonly hideInputStyle?: boolean;
37
37
  readonly isMenuPortaled?: boolean;
38
+ readonly allOptionValue?: string;
39
+ readonly direction?: 'down' | 'up';
40
+ readonly selectedOptionsCount?: number;
38
41
  };
39
42
 
40
43
  type FormComboboxSingleProps = FormComboboxBaseProps & {
@@ -51,6 +54,7 @@ type FormComboboxMultipleProps = FormComboboxBaseProps & {
51
54
  readonly defaultValue?: string[];
52
55
  readonly onChange?: (value: string[]) => void;
53
56
  readonly onSelectionChange?: (selection: FormComboboxOption[] | null) => void;
57
+ readonly isApplyBtnVisible?: boolean;
54
58
  };
55
59
 
56
60
  type FormComboboxProps = FormComboboxSingleProps | FormComboboxMultipleProps;
@@ -125,6 +129,39 @@ const orderSelectedOptionsFirst = (
125
129
  return [...selectedOptions, ...unselectedOptions];
126
130
  };
127
131
 
132
+ const getNextMultipleValues = (
133
+ option: FormComboboxOption,
134
+ options: FormComboboxOption[],
135
+ selectedValues: string[],
136
+ allOptionValue?: string
137
+ ): string[] => {
138
+ if (!allOptionValue) {
139
+ return selectedValues.includes(option.value)
140
+ ? selectedValues.filter((value) => value !== option.value)
141
+ : [...selectedValues, option.value];
142
+ }
143
+
144
+ const optionValues = options.map((item) => item.value);
145
+ const realOptionValues = optionValues.filter((value) => value !== allOptionValue);
146
+
147
+ if (option.value === allOptionValue) {
148
+ return selectedValues.includes(allOptionValue) ? [] : [allOptionValue, ...realOptionValues];
149
+ }
150
+
151
+ const nextRealValues = selectedValues.includes(option.value)
152
+ ? selectedValues.filter((value) => value !== option.value && value !== allOptionValue)
153
+ : [...selectedValues.filter((value) => value !== allOptionValue), option.value];
154
+
155
+ if (
156
+ realOptionValues.length > 0 &&
157
+ realOptionValues.every((value) => nextRealValues.includes(value))
158
+ ) {
159
+ return [allOptionValue, ...realOptionValues];
160
+ }
161
+
162
+ return nextRealValues;
163
+ };
164
+
128
165
  export const FormCombobox: FC<FormComboboxProps> = ({
129
166
  label,
130
167
  hideLabel,
@@ -136,6 +173,8 @@ export const FormCombobox: FC<FormComboboxProps> = ({
136
173
  isSearchEnabled = false,
137
174
  hideInputStyle = false,
138
175
  isMenuPortaled = false,
176
+ allOptionValue,
177
+ direction = 'up',
139
178
  ...props
140
179
  }) => {
141
180
  const id = useId();
@@ -155,11 +194,16 @@ export const FormCombobox: FC<FormComboboxProps> = ({
155
194
  const searchInputRef = useRef<HTMLInputElement | null>(null);
156
195
 
157
196
  const isMultiple = props.multiple === true;
158
- const selectedValues = getSelectedValues(props, internalSingleValue, internalMultipleValue);
197
+ const isApplyBtnVisible = props.multiple === true ? props.isApplyBtnVisible : false;
198
+ const selectedValues = useMemo(() => (
199
+ getSelectedValues(props, internalSingleValue, internalMultipleValue)
200
+ ), [props.multiple, props.value, internalSingleValue, internalMultipleValue]);
201
+ const [draftMultipleValue, setDraftMultipleValue] = useState<string[]>(selectedValues);
202
+ const menuSelectedValues = isApplyBtnVisible ? draftMultipleValue : selectedValues;
159
203
 
160
204
  const orderedOptions = useMemo(() => (
161
- orderSelectedOptionsFirst(options, selectedValues)
162
- ), [options, selectedValues]);
205
+ orderSelectedOptionsFirst(options, menuSelectedValues)
206
+ ), [options, menuSelectedValues]);
163
207
 
164
208
  const filteredOptions = useMemo(() => {
165
209
  const normalizedQuery = query.trim().toLowerCase();
@@ -179,7 +223,7 @@ export const FormCombobox: FC<FormComboboxProps> = ({
179
223
 
180
224
  if (!triggerRect) return;
181
225
 
182
- const offset = 3;
226
+ const offset = 10;
183
227
 
184
228
  setMenuStyle({
185
229
  left: triggerRect.left,
@@ -208,6 +252,12 @@ export const FormCombobox: FC<FormComboboxProps> = ({
208
252
  }
209
253
  }, [isOpen]);
210
254
 
255
+ useEffect(() => {
256
+ if (isOpen && isApplyBtnVisible) {
257
+ setDraftMultipleValue(selectedValues);
258
+ }
259
+ }, [isOpen, isApplyBtnVisible]);
260
+
211
261
  useEffect(() => {
212
262
  const handleDocumentClick = (event: MouseEvent) => {
213
263
  const target = event.target as Node;
@@ -260,11 +310,15 @@ export const FormCombobox: FC<FormComboboxProps> = ({
260
310
  if (!props.multiple) return;
261
311
  const multipleProps = props as FormComboboxMultipleProps;
262
312
 
263
- const nextValues = selectedValues.includes(option.value)
264
- ? selectedValues.filter((value) => value !== option.value)
265
- : [...selectedValues, option.value];
313
+ const currentValues = isApplyBtnVisible ? draftMultipleValue : selectedValues;
314
+ const nextValues = getNextMultipleValues(option, options, currentValues, allOptionValue);
266
315
  const nextSelection = options.filter((item) => nextValues.includes(item.value));
267
316
 
317
+ if (isApplyBtnVisible) {
318
+ setDraftMultipleValue(nextValues);
319
+ return;
320
+ }
321
+
268
322
  if (multipleProps.value === undefined) {
269
323
  setInternalMultipleValue(nextValues);
270
324
  }
@@ -284,10 +338,29 @@ export const FormCombobox: FC<FormComboboxProps> = ({
284
338
  setSingleValue(option);
285
339
  };
286
340
 
341
+ const applyMultipleValue = () => {
342
+ if (!props.multiple) return;
343
+
344
+ const multipleProps = props as FormComboboxMultipleProps;
345
+
346
+ if (multipleProps.value === undefined) {
347
+ setInternalMultipleValue(draftMultipleValue);
348
+ }
349
+
350
+ multipleProps.onChange?.(draftMultipleValue);
351
+ const nextSelection = options.filter((item) => draftMultipleValue.includes(item.value));
352
+
353
+ multipleProps.onSelectionChange?.(nextSelection.length ? nextSelection : null);
354
+ setIsOpen(false);
355
+ setQuery('');
356
+ };
357
+
287
358
  const placeholderValue = placeholder || t('global.choose');
288
359
  const triggerLabel = isMultiple
289
360
  ? selectedOptions.length > 0
290
- ? selectedOptions.map((option) => option.label).join(', ')
361
+ ? props.selectedOptionsCount !== undefined
362
+ ? `${placeholder ?? t('global.chosen')} (${props.selectedOptionsCount})`
363
+ : selectedOptions.map((option) => option.label).join(', ')
291
364
  : placeholderValue
292
365
  : selectedOptions[0]?.label ?? placeholderValue;
293
366
  const triggerContent = hideInputStyle ? label ?? triggerLabel : triggerLabel;
@@ -304,6 +377,7 @@ export const FormCombobox: FC<FormComboboxProps> = ({
304
377
  ref={menuRef}
305
378
  className={clsx(
306
379
  'select__menu select__menu--combobox',
380
+ `select__menu--${direction}`,
307
381
  isMenuPortaled && 'select__menu--portal'
308
382
  )}
309
383
  style={isMenuPortaled ? menuStyle : undefined}
@@ -330,7 +404,7 @@ export const FormCombobox: FC<FormComboboxProps> = ({
330
404
  <ul className='select__options' role='listbox' aria-label={searchPlaceholder ?? t('global.search')}>
331
405
  {filteredOptions.length > 0 ? (
332
406
  filteredOptions.map((option) => {
333
- const isSelected = selectedValues.includes(option.value);
407
+ const isSelected = menuSelectedValues.includes(option.value);
334
408
 
335
409
  return (
336
410
  <li
@@ -354,6 +428,19 @@ export const FormCombobox: FC<FormComboboxProps> = ({
354
428
  })
355
429
  ) : null}
356
430
  </ul>
431
+
432
+ {isApplyBtnVisible && (
433
+ <div className='select__actions'>
434
+ <Button
435
+ size='s'
436
+ type='button'
437
+ onClick={applyMultipleValue}
438
+ disabled={disabled}
439
+ >
440
+ {t('global.apply')}
441
+ </Button>
442
+ </div>
443
+ )}
357
444
  </div>
358
445
  );
359
446
 
@@ -1,10 +1,10 @@
1
- import React, { FC, ReactNode, SelectHTMLAttributes, useId, useState } from 'react';
1
+ import React, { FC, ReactNode, SelectHTMLAttributes, useEffect, useId, useState } from 'react';
2
2
  import { useSelect } from 'downshift';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { MdArrowDropDown } from 'react-icons/md';
6
6
 
7
- import { Icon } from '../..';
7
+ import { Button, Icon } from '../..';
8
8
  import './FormSelect.scss';
9
9
 
10
10
  type SelectOption = { label: string, value: string };
@@ -18,6 +18,58 @@ type FormMultiselectProps = SelectHTMLAttributes<HTMLSelectElement> & {
18
18
  selectedOptions?: SelectOption[];
19
19
  selectedOptionsCount?: number;
20
20
  onSelectionChange?: (selection: SelectOption[] | null) => void;
21
+ isApplyBtnVisible?: boolean;
22
+ allOptionValue?: string;
23
+ };
24
+
25
+ const getNextSelectedItems = (
26
+ selectedItem: SelectOption,
27
+ options: SelectOption[],
28
+ selectedItems: SelectOption[],
29
+ allOptionValue?: string
30
+ ): SelectOption[] => {
31
+ if (!allOptionValue) {
32
+ const index = selectedItems.findIndex((item) => item.value === selectedItem.value);
33
+ const items: SelectOption[] = [];
34
+
35
+ if (index > 0) {
36
+ items.push(
37
+ ...selectedItems.slice(0, index),
38
+ ...selectedItems.slice(index + 1)
39
+ );
40
+ } else if (index === 0) {
41
+ items.push(...selectedItems.slice(1));
42
+ } else {
43
+ items.push(...selectedItems, selectedItem);
44
+ }
45
+
46
+ return items;
47
+ }
48
+
49
+ const realOptions = options.filter((option) => option.value !== allOptionValue);
50
+ const allOption = options.find((option) => option.value === allOptionValue);
51
+
52
+ if (selectedItem.value === allOptionValue) {
53
+ return selectedItems.some((item) => item.value === allOptionValue)
54
+ ? []
55
+ : [...(allOption ? [allOption] : []), ...realOptions];
56
+ }
57
+
58
+ const selectedWithoutAll = selectedItems.filter((item) => item.value !== allOptionValue);
59
+ const isSelected = selectedWithoutAll.some((item) => item.value === selectedItem.value);
60
+ const nextRealItems = isSelected
61
+ ? selectedWithoutAll.filter((item) => item.value !== selectedItem.value)
62
+ : [...selectedWithoutAll, selectedItem];
63
+
64
+ if (
65
+ allOption &&
66
+ realOptions.length > 0 &&
67
+ realOptions.every((option) => nextRealItems.some((item) => item.value === option.value))
68
+ ) {
69
+ return [allOption, ...realOptions];
70
+ }
71
+
72
+ return nextRealItems;
21
73
  };
22
74
 
23
75
  const FormMultiselect: FC<FormMultiselectProps> = (
@@ -31,6 +83,8 @@ const FormMultiselect: FC<FormMultiselectProps> = (
31
83
  selectedOptions,
32
84
  selectedOptionsCount,
33
85
  onSelectionChange,
86
+ isApplyBtnVisible = false,
87
+ allOptionValue,
34
88
  ...rest
35
89
  },
36
90
  ) => {
@@ -63,30 +117,35 @@ const FormMultiselect: FC<FormMultiselectProps> = (
63
117
  if (!selectedItem) {
64
118
  return;
65
119
  }
66
- const index = selectedItems.findIndex((item) => item.value === selectedItem.value);
67
- const items = [];
68
- if (index > 0) {
69
- items.push(
70
- ...selectedItems.slice(0, index),
71
- ...selectedItems.slice(index + 1)
72
- );
73
- } else if (index === 0) {
74
- items.push(...selectedItems.slice(1));
75
- } else {
76
- items.push(...selectedItems, selectedItem);
77
- }
120
+ const items = getNextSelectedItems(selectedItem, options, selectedItems, allOptionValue);
121
+
78
122
  setSelectedItems(items);
79
- if (onSelectionChange) onSelectionChange(items);
123
+ if (!isApplyBtnVisible) {
124
+ onSelectionChange?.(items.length ? items : null);
125
+ }
80
126
  },
81
127
  });
82
128
 
129
+ useEffect(() => {
130
+ setSelectedItems(selectedOptions ?? []);
131
+ }, [selectedOptions]);
132
+
133
+ const applySelection = () => {
134
+ onSelectionChange?.(selectedItems.length ? selectedItems : null);
135
+ };
136
+
83
137
  const selectClasses = clsx(
84
138
  'select',
85
139
  disabled && 'select--disabled',
86
140
  );
87
141
 
88
142
  const placeholderValue = placeholder || t('global.choose');
89
- const displaySelectedCount = selectedOptionsCount ?? selectedItems.length;
143
+ const selectedItemsCount = allOptionValue
144
+ ? selectedItems.filter((item) => item.value !== allOptionValue).length
145
+ : selectedItems.length;
146
+ const displaySelectedCount = isApplyBtnVisible
147
+ ? selectedItemsCount
148
+ : selectedOptionsCount ?? selectedItems.length;
90
149
 
91
150
  return (
92
151
  <div className={selectClasses} style={rest.style}>
@@ -97,27 +156,40 @@ const FormMultiselect: FC<FormMultiselectProps> = (
97
156
  <Icon label='Dropdown icon' size='medium' icon={<MdArrowDropDown color='#5D6071' />} />
98
157
  </div>
99
158
 
100
- <ul className='select__menu' {...getMenuProps()}>
101
- {isOpen &&
102
- options.map((item, index) => (
103
- <li
104
- key={`${item.label}-${index}`}
105
- className={clsx('select__option', { 'select__option--selected': highlightedIndex === index })}
106
- {...getItemProps({
107
- item,
108
- index,
109
- })}
159
+ <div className='select__menu select__menu--multiselect'>
160
+ <ul className='select__options' {...getMenuProps()}>
161
+ {isOpen &&
162
+ options.map((item, index) => (
163
+ <li
164
+ key={`${item.label}-${index}`}
165
+ className={clsx('select__option', { 'select__option--selected': highlightedIndex === index })}
166
+ {...getItemProps({
167
+ item,
168
+ index,
169
+ })}
170
+ >
171
+ <input
172
+ type='checkbox'
173
+ checked={selectedItems.map((s) => s.value).includes(item.value)}
174
+ value={item.value}
175
+ onChange={() => null}
176
+ />
177
+ <span>{item.label}</span>
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ {isOpen && isApplyBtnVisible && (
182
+ <div className='select__actions'>
183
+ <Button
184
+ size='s'
185
+ type='button'
186
+ onClick={applySelection}
110
187
  >
111
- <input
112
- type='checkbox'
113
- checked={selectedItems.map((s) => s.value).includes(item.value)}
114
- value={item.value}
115
- onChange={() => null}
116
- />
117
- <span>{item.label}</span>
118
- </li>
119
- ))}
120
- </ul>
188
+ {t('global.apply')}
189
+ </Button>
190
+ </div>
191
+ )}
192
+ </div>
121
193
  </div>
122
194
  </div>
123
195
  );
@@ -48,6 +48,10 @@
48
48
  display: block;
49
49
  }
50
50
 
51
+ + #{$self}__menu--multiselect {
52
+ display: flex;
53
+ }
54
+
51
55
  +#{$self}__menu_up {
52
56
  display: block;
53
57
  }
@@ -74,6 +78,21 @@
74
78
  margin-top: 3px;
75
79
  }
76
80
 
81
+ &__menu--multiselect {
82
+ flex-direction: column;
83
+ overflow: hidden;
84
+ }
85
+
86
+ &__options {
87
+ flex: 1 1 auto;
88
+ max-height: 224px;
89
+ min-height: 0;
90
+ overflow: auto;
91
+ list-style: none;
92
+ margin: 0;
93
+ padding: 0;
94
+ }
95
+
77
96
  &__menu_up {
78
97
  display: none;
79
98
  position: absolute;
@@ -118,4 +137,17 @@
118
137
  color: get-color(black-coral-20);
119
138
  }
120
139
  }
140
+
141
+ &__actions {
142
+ display: flex;
143
+ flex: 0 0 auto;
144
+ justify-content: center;
145
+ padding: 12px 16px 16px;
146
+
147
+ > .btn.btn--s {
148
+ justify-content: center;
149
+ min-width: 106px;
150
+ padding: 4px 16px;
151
+ }
152
+ }
121
153
  }