@controleonline/ui-default 1.0.267 → 1.0.269

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/AGENTS.md CHANGED
@@ -21,8 +21,9 @@
21
21
  - `requestParams` serve para contexto fixo do fluxo, nao para duplicar estado de filtro que ja vive no store.
22
22
  - `pageSize` so deve ser usado quando o contrato externo exigir e com justificativa clara no modulo.
23
23
  - `summary` e `totalItems` vem do backend/store e pertencem ao rodape interno do `DefaultTable`.
24
- - `add: true` abre o `DefaultForm` quando a tela nao prover `onAdd`.
25
- - O modo controlado continua existindo apenas para excecoes reais de integracao; o padrao do sistema e o modo store-driven.
24
+ - `add: true` abre o `DefaultForm` quando a tela nao prover `onAdd`.
25
+ - `rowActionsComponent` e o slot canonico para acoes por linha quando a tela precisa renderizar mais do que o botao padrao de edicao. Ele pode ser componente, funcao ou elemento React, recebe `row`, `storeName`, `openEdit`, `openRow` e `helpers`, e a largura da coluna deve ser ajustada com `rowActionsWidth` quando houver mais de uma acao.
26
+ - O modo controlado continua existindo apenas para excecoes reais de integracao; o padrao do sistema e o modo store-driven.
26
27
  - Loadings e erros devem seguir o fluxo central do sistema, sem banners ou estados paralelos na tela.
27
28
  - O `get` do store default pode preservar o `item` atual durante refresh quando receber `__storeMeta.preserveItem = true`; use esse modo quando a tela precisa atualizar o registro sem desmontar o detalhe em exibição.
28
29
  - Explicacoes permanentes de tela devem sair do corpo principal e ir para o `DefaultTooltip`, acionado por `?`, quando houver contexto necessario para o usuario.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-default",
3
- "version": "1.0.267",
3
+ "version": "1.0.269",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Default Quasar UI Component",
6
6
  "license": "MIT",
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useMemo, useState } from 'react';
2
- import {
2
+ import {
3
+ Image,
3
4
  Modal,
4
5
  ScrollView,
5
6
  Text,
@@ -10,12 +11,13 @@ import {
10
11
  View,
11
12
  } from 'react-native';
12
13
  import Icon from 'react-native-vector-icons/Feather';
13
- import {
14
- formatStoreColumnLabel,
15
- resolveStoreColumn,
16
- resolveStoreConfigByName,
17
- } from '@controleonline/ui-common/src/react/utils/storeColumns';
18
- import createStyles from './CompactFilterSelector.styles';
14
+ import {
15
+ formatStoreColumnLabel,
16
+ resolveStoreColumn,
17
+ resolveStoreConfigByName,
18
+ } from '@controleonline/ui-common/src/react/utils/storeColumns';
19
+ import { isValidFeatherIcon } from '../inputs/defaultInputUtils';
20
+ import createStyles from './CompactFilterSelector.styles';
19
21
 
20
22
  const buildTheme = ({ accentColor, themeColors = {} }) => ({
21
23
  accentColor: accentColor,
@@ -117,8 +119,23 @@ const CompactFilterSelector = ({
117
119
  field,
118
120
  store,
119
121
  });
120
- const resolvedLabelCaption = labelCaption || storeFieldLabel;
121
- const resolvedTitle = title || storeFieldLabel;
122
+ const resolvedLabelCaption = labelCaption || storeFieldLabel;
123
+ const resolvedTitle = title || storeFieldLabel;
124
+ const selectedOption = useMemo(
125
+ () => options.find(option => option?.key === selectedKey),
126
+ [options, selectedKey],
127
+ );
128
+ const selectedOptionColor = normalizeText(selectedOption?.color);
129
+ const selectedOptionIcon = normalizeText(selectedOption?.icon);
130
+ const selectedOptionIconName = isValidFeatherIcon(selectedOptionIcon)
131
+ ? selectedOptionIcon
132
+ : '';
133
+ const triggerIconName = active && selectedOptionIconName
134
+ ? selectedOptionIconName
135
+ : icon;
136
+ const triggerIconColor = active
137
+ ? selectedOptionColor || resolvedTheme.activeIconColor
138
+ : resolvedTheme.iconColor;
122
139
 
123
140
  const closeModal = useCallback(() => {
124
141
  setVisible(false);
@@ -201,22 +218,31 @@ const CompactFilterSelector = ({
201
218
  disabled={disabled}
202
219
  onPress={openModal}
203
220
  >
204
- <View style={[styles.iconWrap, active ? styles.iconWrapActive : null]}>
205
- <Icon
206
- name={icon}
207
- size={dense ? 14 : 15}
208
- color={active ? resolvedTheme.activeIconColor : resolvedTheme.iconColor}
209
- />
210
- </View>
221
+ <View style={[styles.iconWrap, active ? styles.iconWrapActive : null]}>
222
+ {active && selectedOption?.image ? (
223
+ <Image
224
+ source={selectedOption.image}
225
+ style={styles.triggerIconImage}
226
+ resizeMode="contain"
227
+ />
228
+ ) : (
229
+ <Icon
230
+ name={triggerIconName}
231
+ size={dense ? 14 : 15}
232
+ color={triggerIconColor}
233
+ />
234
+ )}
235
+ </View>
211
236
 
212
237
  <View style={styles.textWrap}>
213
238
  {!!resolvedLabelCaption && (
214
239
  <Text
215
240
  numberOfLines={1}
216
241
  style={[
217
- styles.triggerCaption,
218
- active ? styles.triggerCaptionActive : null,
219
- ]}
242
+ styles.triggerCaption,
243
+ active ? styles.triggerCaptionActive : null,
244
+ active && selectedOptionColor ? { color: selectedOptionColor } : null,
245
+ ]}
220
246
  >
221
247
  {resolvedLabelCaption}
222
248
  </Text>
@@ -224,7 +250,11 @@ const CompactFilterSelector = ({
224
250
 
225
251
  <Text
226
252
  numberOfLines={1}
227
- style={[styles.triggerText, active ? styles.triggerTextActive : null]}
253
+ style={[
254
+ styles.triggerText,
255
+ active ? styles.triggerTextActive : null,
256
+ active && selectedOptionColor ? { color: selectedOptionColor } : null,
257
+ ]}
228
258
  >
229
259
  {label}
230
260
  </Text>
@@ -270,33 +300,74 @@ const CompactFilterSelector = ({
270
300
  ) : null}
271
301
 
272
302
  {filteredOptions.map(option => {
273
- const isSelected = option.key === selectedKey;
274
-
275
- return (
303
+ const isSelected = option.key === selectedKey;
304
+ const optionColor = normalizeText(option?.color);
305
+ const optionIcon = normalizeText(option?.icon);
306
+ const optionIconName = isValidFeatherIcon(optionIcon)
307
+ ? optionIcon
308
+ : '';
309
+ const shouldRenderOptionFallbackText = Boolean(
310
+ !option?.image &&
311
+ !optionIconName &&
312
+ optionColor &&
313
+ normalizeText(option?.label),
314
+ );
315
+ const hasOptionVisual = Boolean(option?.image || optionIconName || shouldRenderOptionFallbackText);
316
+
317
+ return (
276
318
  <TouchableOpacity
277
319
  key={option.key}
278
320
  style={[
279
321
  styles.modalOption,
280
322
  isSelected ? styles.modalOptionActive : null,
281
323
  ]}
282
- activeOpacity={0.9}
283
- onPress={() => handleSelect(option.key)}
284
- >
285
- <Text
286
- style={[
287
- styles.modalOptionText,
288
- isSelected ? styles.modalOptionTextActive : null,
289
- ]}
290
- >
291
- {option.label}
292
- </Text>
324
+ activeOpacity={0.9}
325
+ onPress={() => handleSelect(option.key)}
326
+ >
327
+ {option?.image ? (
328
+ <Image
329
+ source={option.image}
330
+ style={styles.modalOptionImage}
331
+ resizeMode="contain"
332
+ />
333
+ ) : optionIconName ? (
334
+ <Icon
335
+ style={styles.modalOptionIcon}
336
+ name={optionIconName}
337
+ size={16}
338
+ color={optionColor || resolvedTheme.iconColor}
339
+ />
340
+ ) : shouldRenderOptionFallbackText ? (
341
+ <Text
342
+ style={[
343
+ styles.modalOptionFallbackText,
344
+ { color: optionColor },
345
+ ]}
346
+ numberOfLines={1}
347
+ >
348
+ {option.label}
349
+ </Text>
350
+ ) : null}
351
+
352
+ {shouldRenderOptionFallbackText ? null : (
353
+ <Text
354
+ style={[
355
+ styles.modalOptionText,
356
+ isSelected ? styles.modalOptionTextActive : null,
357
+ optionColor ? { color: optionColor } : null,
358
+ !hasOptionVisual ? styles.modalOptionTextNoVisual : null,
359
+ ]}
360
+ >
361
+ {option.label}
362
+ </Text>
363
+ )}
293
364
 
294
365
  {isSelected ? (
295
- <Icon
296
- name="check"
297
- size={16}
298
- color={resolvedTheme.activeIconColor}
299
- />
366
+ <Icon
367
+ name="check"
368
+ size={16}
369
+ color={optionColor || resolvedTheme.activeIconColor}
370
+ />
300
371
  ) : null}
301
372
  </TouchableOpacity>
302
373
  );
@@ -29,9 +29,14 @@ const createStyles = (theme, dense = false, windowWidth = 0) => {
29
29
  justifyContent: 'center',
30
30
  backgroundColor: theme.iconBackgroundColor,
31
31
  },
32
- iconWrapActive: {
33
- backgroundColor: theme.activeIconBackgroundColor,
34
- },
32
+ iconWrapActive: {
33
+ backgroundColor: theme.activeIconBackgroundColor,
34
+ },
35
+ triggerIconImage: {
36
+ width: dense ? 16 : 18,
37
+ height: dense ? 16 : 18,
38
+ flexShrink: 0,
39
+ },
35
40
  textWrap: {
36
41
  flex: 1,
37
42
  minWidth: 0,
@@ -105,7 +110,7 @@ const createStyles = (theme, dense = false, windowWidth = 0) => {
105
110
  fontWeight: '700',
106
111
  },
107
112
  modalOption: {
108
- minHeight: 50,
113
+ minHeight: 50,
109
114
  borderWidth: 1,
110
115
  borderColor: theme.optionBorderColor,
111
116
  borderRadius: 16,
@@ -115,18 +120,35 @@ const createStyles = (theme, dense = false, windowWidth = 0) => {
115
120
  flexDirection: 'row',
116
121
  alignItems: 'center',
117
122
  justifyContent: 'space-between',
118
- gap: 8,
119
- },
120
- modalOptionActive: {
121
- borderColor: theme.accentColor,
122
- backgroundColor: theme.activeBackgroundColor,
123
+ gap: 8,
124
+ },
125
+ modalOptionIcon: {
126
+ flexShrink: 0,
127
+ },
128
+ modalOptionImage: {
129
+ width: 18,
130
+ height: 18,
131
+ flexShrink: 0,
132
+ },
133
+ modalOptionFallbackText: {
134
+ flex: 1,
135
+ minWidth: 0,
136
+ fontSize: 14,
137
+ fontWeight: '800',
138
+ },
139
+ modalOptionActive: {
140
+ borderColor: theme.accentColor,
141
+ backgroundColor: theme.activeBackgroundColor,
123
142
  },
124
143
  modalOptionText: {
125
144
  flex: 1,
126
145
  fontSize: 14,
127
- fontWeight: '700',
128
- color: theme.textColor,
129
- },
146
+ fontWeight: '700',
147
+ color: theme.textColor,
148
+ },
149
+ modalOptionTextNoVisual: {
150
+ marginLeft: 0,
151
+ },
130
152
  modalOptionTextActive: {
131
153
  color: theme.optionSelectedTextColor,
132
154
  },
@@ -6,6 +6,7 @@ import {
6
6
  getColumnKey,
7
7
  isDateLikeColumn,
8
8
  isEditableColumn,
9
+ isValidFeatherIcon,
9
10
  normalizeText,
10
11
  resolveCellPresentation,
11
12
  resolveCellText,
@@ -142,6 +143,17 @@ const DefaultInput = ({
142
143
  };
143
144
 
144
145
  if (!editing && !isForm) {
146
+ const readIconName = isValidFeatherIcon(readPresentation.icon)
147
+ ? readPresentation.icon
148
+ : '';
149
+ const shouldRenderIconFallbackText = Boolean(
150
+ readPresentationStyles.hasDecoration &&
151
+ !readIconName &&
152
+ readPresentationStyles.color &&
153
+ normalizeText(resolvedLabel) &&
154
+ resolvedLabel !== '-',
155
+ );
156
+
145
157
  return (
146
158
  <View style={[styles.wrap, containerStyle]}>
147
159
  {showLabel ? <Text style={styles.fieldLabel}>{label || column?.label || fieldName}</Text> : null}
@@ -151,43 +163,57 @@ const DefaultInput = ({
151
163
  disabled={!canEdit}
152
164
  onPress={() => onStartEditing?.()}
153
165
  >
154
- <View
155
- style={[
156
- styles.readValueWrap,
157
- readPresentationStyles.hasDecoration ? styles.readBadge : null,
158
- readPresentationStyles.badgeStyle,
159
- ]}
160
- >
161
- {readPresentation.image ? (
162
- <Image
163
- source={readPresentation.image}
164
- style={styles.readImage}
165
- resizeMode="contain"
166
- />
167
- ) : null}
168
- {readPresentationStyles.hasDecoration && readPresentation.icon && readPresentationStyles.color ? (
169
- <Icon
170
- style={styles.readBadgeIcon}
171
- name={readPresentation.icon}
172
- size={12}
173
- color={readPresentationStyles.color}
174
- />
175
- ) : null}
166
+ {shouldRenderIconFallbackText ? (
176
167
  <Text
177
168
  style={[
178
169
  styles.readText,
179
- readPresentationStyles.hasDecoration ? styles.readBadgeText : null,
180
- resolvedLabel === '-' ? styles.mutedText : null,
170
+ styles.readTextBadge,
181
171
  readPresentationStyles.color ? { color: readPresentationStyles.color } : null,
182
172
  readTextStyle,
183
173
  ]}
184
174
  numberOfLines={numberOfLines}
185
175
  >
186
- {column?.prefix || ''}
187
- {resolvedLabel || '-'}
188
- {column?.sufix || column?.suffix || ''}
176
+ {resolvedLabel}
189
177
  </Text>
190
- </View>
178
+ ) : (
179
+ <View
180
+ style={[
181
+ styles.readValueWrap,
182
+ readPresentationStyles.hasDecoration ? styles.readBadge : null,
183
+ readPresentationStyles.badgeStyle,
184
+ ]}
185
+ >
186
+ {readPresentation.image ? (
187
+ <Image
188
+ source={readPresentation.image}
189
+ style={styles.readImage}
190
+ resizeMode="contain"
191
+ />
192
+ ) : null}
193
+ {readPresentationStyles.hasDecoration && readIconName && readPresentationStyles.color ? (
194
+ <Icon
195
+ style={styles.readBadgeIcon}
196
+ name={readIconName}
197
+ size={12}
198
+ color={readPresentationStyles.color}
199
+ />
200
+ ) : null}
201
+ <Text
202
+ style={[
203
+ styles.readText,
204
+ readPresentationStyles.hasDecoration ? styles.readBadgeText : null,
205
+ resolvedLabel === '-' ? styles.mutedText : null,
206
+ readPresentationStyles.color ? { color: readPresentationStyles.color } : null,
207
+ readTextStyle,
208
+ ]}
209
+ numberOfLines={numberOfLines}
210
+ >
211
+ {column?.prefix || ''}
212
+ {resolvedLabel || '-'}
213
+ {column?.sufix || column?.suffix || ''}
214
+ </Text>
215
+ </View>
216
+ )}
191
217
  {saving ? (
192
218
  <Text style={[styles.savingText, { color: accentColor }]}>Salvando</Text>
193
219
  ) : canEdit ? (
@@ -24,6 +24,7 @@ const styles = StyleSheet.create({
24
24
  readBadge: {
25
25
  flex: 0,
26
26
  alignSelf: 'flex-start',
27
+ minWidth: 22,
27
28
  minHeight: 22,
28
29
  maxWidth: '100%',
29
30
  borderWidth: 1,
@@ -33,10 +34,15 @@ const styles = StyleSheet.create({
33
34
  },
34
35
  readBadgeText: {
35
36
  flex: 0,
36
- flexShrink: 1,
37
+ flexShrink: 0,
38
+ maxWidth: '100%',
37
39
  fontSize: 11,
38
40
  fontWeight: '800',
39
41
  },
42
+ readTextBadge: {
43
+ flex: 1,
44
+ maxWidth: '100%',
45
+ },
40
46
  readBadgeIcon: {
41
47
  flexShrink: 0,
42
48
  },
@@ -15,6 +15,7 @@ import {
15
15
  buildOptionsFromColumn,
16
16
  getColumnKey,
17
17
  isEditableColumn,
18
+ isValidFeatherIcon,
18
19
  mapOptions,
19
20
  normalizeOptionKey,
20
21
  normalizeText,
@@ -159,7 +160,17 @@ const DefaultSelect = ({
159
160
  [readPresentation, resolvedLabel],
160
161
  );
161
162
  const canEdit = isEditableColumn(column) && typeof onStartEditing === 'function';
162
- const isForm = variant === 'form';
163
+ const isForm = variant === 'form';
164
+ const readIconName = isValidFeatherIcon(readPresentation.icon)
165
+ ? readPresentation.icon
166
+ : '';
167
+ const shouldRenderIconFallbackText = Boolean(
168
+ readPresentationStyles.hasDecoration &&
169
+ !readIconName &&
170
+ readPresentationStyles.color &&
171
+ normalizeText(resolvedLabel) &&
172
+ resolvedLabel !== '-',
173
+ );
163
174
  const filteredOptions = useMemo(() => {
164
175
  const query = normalizeText(searchText).toLowerCase();
165
176
  if (!query || onSearchChange) return options;
@@ -290,47 +301,61 @@ const DefaultSelect = ({
290
301
  <View style={[inputStyles.wrap, containerStyle]}>
291
302
  {showLabel ? <Text style={inputStyles.fieldLabel}>{label || column?.label || fieldName}</Text> : null}
292
303
 
293
- <TouchableOpacity
294
- style={[inputStyles.readButton, isForm ? inputStyles.readButtonForm : null]}
295
- activeOpacity={canEdit || isForm ? 0.78 : 1}
296
- disabled={!canEdit && !isForm}
297
- onPress={open}
304
+ <TouchableOpacity
305
+ style={[inputStyles.readButton, isForm ? inputStyles.readButtonForm : null]}
306
+ activeOpacity={canEdit || isForm ? 0.78 : 1}
307
+ disabled={!canEdit && !isForm}
308
+ onPress={open}
298
309
  >
299
- <View
300
- style={[
301
- inputStyles.readValueWrap,
302
- readPresentationStyles.hasDecoration ? inputStyles.readBadge : null,
303
- readPresentationStyles.badgeStyle,
304
- ]}
305
- >
306
- {readPresentation.image ? (
307
- <Image
308
- source={readPresentation.image}
309
- style={inputStyles.readImage}
310
- resizeMode="contain"
311
- />
312
- ) : null}
313
- {readPresentationStyles.hasDecoration && readPresentation.icon && readPresentationStyles.color ? (
314
- <Icon
315
- style={inputStyles.readBadgeIcon}
316
- name={readPresentation.icon}
317
- size={12}
318
- color={readPresentationStyles.color}
319
- />
320
- ) : null}
310
+ {shouldRenderIconFallbackText ? (
321
311
  <Text
322
312
  style={[
323
313
  inputStyles.readText,
324
- readPresentationStyles.hasDecoration ? inputStyles.readBadgeText : null,
325
- resolvedLabel === '-' ? inputStyles.mutedText : null,
314
+ inputStyles.readTextBadge,
326
315
  readPresentationStyles.color ? { color: readPresentationStyles.color } : null,
327
316
  readTextStyle,
328
317
  ]}
329
318
  numberOfLines={numberOfLines}
330
319
  >
331
- {resolvedLabel || '-'}
320
+ {resolvedLabel}
332
321
  </Text>
333
- </View>
322
+ ) : (
323
+ <View
324
+ style={[
325
+ inputStyles.readValueWrap,
326
+ readPresentationStyles.hasDecoration ? inputStyles.readBadge : null,
327
+ readPresentationStyles.badgeStyle,
328
+ ]}
329
+ >
330
+ {readPresentation.image ? (
331
+ <Image
332
+ source={readPresentation.image}
333
+ style={inputStyles.readImage}
334
+ resizeMode="contain"
335
+ />
336
+ ) : null}
337
+ {readPresentationStyles.hasDecoration && readIconName && readPresentationStyles.color ? (
338
+ <Icon
339
+ style={inputStyles.readBadgeIcon}
340
+ name={readIconName}
341
+ size={12}
342
+ color={readPresentationStyles.color}
343
+ />
344
+ ) : null}
345
+ <Text
346
+ style={[
347
+ inputStyles.readText,
348
+ readPresentationStyles.hasDecoration ? inputStyles.readBadgeText : null,
349
+ resolvedLabel === '-' ? inputStyles.mutedText : null,
350
+ readPresentationStyles.color ? { color: readPresentationStyles.color } : null,
351
+ readTextStyle,
352
+ ]}
353
+ numberOfLines={numberOfLines}
354
+ >
355
+ {resolvedLabel || '-'}
356
+ </Text>
357
+ </View>
358
+ )}
334
359
  {saving ? (
335
360
  <Text style={[inputStyles.savingText, { color: accentColor }]}>Salvando</Text>
336
361
  ) : canEdit || isForm ? (
@@ -363,6 +388,16 @@ const DefaultSelect = ({
363
388
  {filteredOptions.length > 0 ? (
364
389
  filteredOptions.map(option => {
365
390
  const isSelected = option.key === selectedKey;
391
+ const optionColor = normalizeText(option.color);
392
+ const optionIconName = isValidFeatherIcon(option.icon)
393
+ ? option.icon
394
+ : '';
395
+ const shouldRenderOptionFallbackText = Boolean(
396
+ !option.image &&
397
+ !optionIconName &&
398
+ optionColor &&
399
+ normalizeText(option.label),
400
+ );
366
401
  return (
367
402
  <TouchableOpacity
368
403
  key={`${option.key}_${option.label}`}
@@ -377,14 +412,20 @@ const DefaultSelect = ({
377
412
  resizeMode="contain"
378
413
  />
379
414
  ) : null}
380
- {isSelected || (option.icon && option.color) ? (
415
+ {isSelected || (optionIconName && optionColor) ? (
381
416
  <Icon
382
- name={isSelected ? 'check-circle' : option.icon}
417
+ name={isSelected ? 'check-circle' : optionIconName}
383
418
  size={15}
384
- color={isSelected ? accentColor : option.color}
419
+ color={isSelected ? accentColor : optionColor}
385
420
  />
386
421
  ) : null}
387
- <Text style={[styles.optionText, option.color ? { color: option.color } : null]}>
422
+ <Text
423
+ style={[
424
+ styles.optionText,
425
+ optionColor ? { color: optionColor } : null,
426
+ shouldRenderOptionFallbackText ? styles.optionFallbackText : null,
427
+ ]}
428
+ >
388
429
  {option.label}
389
430
  </Text>
390
431
  </TouchableOpacity>
@@ -51,6 +51,9 @@ const styles = StyleSheet.create({
51
51
  fontSize: 13,
52
52
  fontWeight: '700',
53
53
  },
54
+ optionFallbackText: {
55
+ fontWeight: '800',
56
+ },
54
57
  emptyBox: {
55
58
  padding: 18,
56
59
  alignItems: 'center',
@@ -1,9 +1,11 @@
1
- import { getAllStores } from '@store';
2
- import Formatter from '@controleonline/ui-common/src/utils/formatter.js';
3
- import { formatStoreColumnValue } from '@controleonline/ui-common/src/react/utils/storeColumns';
1
+ import { getAllStores } from '@store';
2
+ import Formatter from '@controleonline/ui-common/src/utils/formatter.js';
3
+ import { formatStoreColumnValue } from '@controleonline/ui-common/src/react/utils/storeColumns';
4
+ import FeatherGlyphMap from 'react-native-vector-icons/glyphmaps/Feather.json';
4
5
 
5
- export const normalizeText = value => String(value ?? '').trim();
6
- const formatHumanLabel = value =>
6
+ export const normalizeText = value => String(value ?? '').trim();
7
+ export const isValidFeatherIcon = icon => Boolean(FeatherGlyphMap[normalizeText(icon)]);
8
+ const formatHumanLabel = value =>
7
9
  normalizeText(value)
8
10
  .replace(/([a-z])([A-Z])/g, '$1 $2')
9
11
  .replace(/[_-]+/g, ' ')
@@ -242,6 +244,15 @@ const resolveFormattedListMeta = (column, rawValue, row) => {
242
244
  return normalizeObject(formatted);
243
245
  };
244
246
 
247
+ const resolveFormattedValueMeta = (column, rawValue, row) => {
248
+ if (typeof column?.format !== 'function') {
249
+ return {};
250
+ }
251
+
252
+ const formatted = column.format(rawValue, column, row, false);
253
+ return normalizeObject(formatted);
254
+ };
255
+
245
256
  const resolveStyleMeta = (column, row) => {
246
257
  if (typeof column?.style !== 'function') {
247
258
  return normalizeObject(column?.style);
@@ -260,6 +271,7 @@ export const resolveCellPresentation = ({
260
271
  const fieldName = getColumnKey(column);
261
272
  const rawValue = value ?? row?.[fieldName];
262
273
  const rawMeta = normalizeObject(rawValue);
274
+ const formattedValueMeta = resolveFormattedValueMeta(column, rawValue, row);
263
275
  const formattedMeta = resolveFormattedListMeta(column, rawValue, row);
264
276
  const styleMeta = resolveStyleMeta(column, row);
265
277
  const label = resolveCellText({
@@ -272,23 +284,29 @@ export const resolveCellPresentation = ({
272
284
 
273
285
  return {
274
286
  backgroundColor:
287
+ formattedValueMeta.backgroundColor ??
275
288
  formattedMeta.backgroundColor ??
289
+ formattedValueMeta.bgColor ??
276
290
  formattedMeta.bgColor ??
277
291
  rawMeta.backgroundColor ??
278
292
  rawMeta.bgColor ??
279
293
  styleMeta.backgroundColor ??
280
294
  styleMeta.bgColor,
281
295
  borderColor:
296
+ formattedValueMeta.borderColor ??
282
297
  formattedMeta.borderColor ??
283
298
  rawMeta.borderColor ??
284
299
  styleMeta.borderColor,
285
300
  color:
301
+ formattedValueMeta.color ??
286
302
  formattedMeta.color ??
287
303
  rawMeta.color ??
288
304
  rawMeta.statusColor ??
289
305
  rawMeta.categoryColor ??
290
306
  styleMeta.color,
291
307
  icon:
308
+ formattedValueMeta.icon ??
309
+ formattedValueMeta.iconName ??
292
310
  formattedMeta.icon ??
293
311
  formattedMeta.iconName ??
294
312
  rawMeta.icon ??
@@ -296,6 +314,9 @@ export const resolveCellPresentation = ({
296
314
  styleMeta.icon ??
297
315
  styleMeta.iconName,
298
316
  image:
317
+ formattedValueMeta.image ??
318
+ formattedValueMeta.logo ??
319
+ formattedValueMeta.source ??
299
320
  formattedMeta.image ??
300
321
  formattedMeta.logo ??
301
322
  formattedMeta.source ??
@@ -77,6 +77,7 @@ const DefaultTable = ({
77
77
  renderCard = null,
78
78
  requestParams = {},
79
79
  rowActionsComponent = null,
80
+ rowActionsWidth = null,
80
81
  rowStyle = null,
81
82
  showColumnFiltersButton = true,
82
83
  showRowActions = true,
@@ -221,6 +222,7 @@ const DefaultTable = ({
221
222
  requestSort,
222
223
  resolvedSort,
223
224
  rowActionsComponent,
225
+ rowActionsWidth,
224
226
  rowStyle,
225
227
  showColumnFiltersButton,
226
228
  showRowActions,
@@ -260,6 +262,7 @@ const DefaultTable = ({
260
262
  requestSort,
261
263
  resolvedSort,
262
264
  rowActionsComponent,
265
+ rowActionsWidth,
263
266
  rowStyle,
264
267
  showColumnFiltersButton,
265
268
  showRowActions,
@@ -279,12 +282,13 @@ const DefaultTable = ({
279
282
  stableSerialize({
280
283
  add,
281
284
  compactBreakpoint,
282
- debugFallbackParameters,
283
285
  effectiveViewMode,
284
286
  filters,
285
287
  forceCardsOnCompact,
286
288
  initialViewMode,
287
289
  pinRowActions,
290
+ rowActionsComponentType: rowActionsComponent ? typeof rowActionsComponent : '',
291
+ rowActionsWidth,
288
292
  resolvedSort,
289
293
  showColumnFiltersButton,
290
294
  showRowActions,
@@ -305,6 +309,8 @@ const DefaultTable = ({
305
309
  forceCardsOnCompact,
306
310
  initialViewMode,
307
311
  pinRowActions,
312
+ rowActionsComponent,
313
+ rowActionsWidth,
308
314
  resolvedSort,
309
315
  showColumnFiltersButton,
310
316
  showRowActions,
@@ -445,9 +445,11 @@ const styles = StyleSheet.create({
445
445
  },
446
446
  rowActionsGroup: {
447
447
  flexDirection: 'row',
448
+ flexWrap: 'wrap',
448
449
  alignItems: 'center',
449
450
  justifyContent: 'center',
450
451
  gap: 6,
452
+ width: '100%',
451
453
  },
452
454
  iconButton: {
453
455
  width: 28,
@@ -485,14 +485,44 @@ export const resolveSortComparable = ({ column, row, storeName, columns }) => {
485
485
 
486
486
  export const getColumnStyle = column => {
487
487
  const key = getColumnKey(column);
488
- if (column?.isIdentity) return [styles.cell, styles.identityCell];
488
+ const customSize = {};
489
+ const columnWidth = Number(column?.width);
490
+ const columnMinWidth = Number(column?.minWidth);
491
+ const columnFlexBasis = Number(column?.flexBasis);
492
+
493
+ if (Number.isFinite(columnWidth) && columnWidth > 0) {
494
+ customSize.width = columnWidth;
495
+ customSize.flexBasis = columnWidth;
496
+ }
497
+
498
+ if (Number.isFinite(columnMinWidth) && columnMinWidth > 0) {
499
+ customSize.minWidth = columnMinWidth;
500
+ }
501
+
502
+ if (Number.isFinite(columnFlexBasis) && columnFlexBasis > 0) {
503
+ customSize.flexBasis = columnFlexBasis;
504
+ }
505
+
506
+ const columnSizeStyle = Object.keys(customSize).length ? customSize : null;
507
+
508
+ if (column?.isIdentity) return [styles.cell, styles.identityCell, columnSizeStyle];
489
509
  if (['price', 'total', 'amount', 'value'].includes(key)) {
490
- return [styles.cell, styles.moneyCell];
510
+ return [styles.cell, styles.moneyCell, columnSizeStyle];
491
511
  }
492
- return styles.cell;
512
+ return [styles.cell, columnSizeStyle];
493
513
  };
494
514
 
495
515
  export const getColumnMinWidth = column => {
516
+ const columnMinWidth = Number(column?.minWidth);
517
+ if (Number.isFinite(columnMinWidth) && columnMinWidth > 0) {
518
+ return columnMinWidth;
519
+ }
520
+
521
+ const columnWidth = Number(column?.width);
522
+ if (Number.isFinite(columnWidth) && columnWidth > 0) {
523
+ return columnWidth;
524
+ }
525
+
496
526
  const key = getColumnKey(column);
497
527
  if (column?.isIdentity) return IDENTITY_CELL_MIN_WIDTH;
498
528
  if (['price', 'total', 'amount', 'value'].includes(key)) {
@@ -5,6 +5,9 @@ import { formatStoreColumnLabel } from '@controleonline/ui-common/src/react/util
5
5
  import { getColumnKey, resolveCellText } from '../inputs/defaultInputUtils';
6
6
  import DefaultTableEmptyState from './DefaultTableEmptyState';
7
7
  import DefaultTableInput from './DefaultTableInput';
8
+ import DefaultTableRowActions, {
9
+ hasDefaultTableRowActionsComponent,
10
+ } from './DefaultTableRowActions';
8
11
  import {
9
12
  END_REACHED_THRESHOLD,
10
13
  getRowKey,
@@ -63,14 +66,20 @@ const DefaultTableCards = ({ storeName }) => {
63
66
  };
64
67
  const RowActionsComponent = configs.rowActionsComponent;
65
68
  const hasRowPress = typeof configs.onRowPress === 'function';
66
- const hasCustomRowActions = typeof RowActionsComponent === 'function';
69
+ const hasCustomRowActions = hasDefaultTableRowActionsComponent(RowActionsComponent);
67
70
  const hasEditAction = typeof configs.onEditRow === 'function';
68
71
  const hasRowActions = configs.showRowActions !== false && (hasCustomRowActions || hasEditAction);
69
72
  const customRowActions = hasCustomRowActions ? (
70
- <RowActionsComponent
73
+ <DefaultTableRowActions
74
+ component={RowActionsComponent}
75
+ helpers={{
76
+ openEdit: () => configs.onEditRow?.(row),
77
+ openRow: hasRowPress ? () => configs.onRowPress(row) : null,
78
+ }}
71
79
  openEdit={() => configs.onEditRow?.(row)}
72
80
  openRow={hasRowPress ? () => configs.onRowPress(row) : null}
73
81
  row={row}
82
+ storeName={storeName}
74
83
  />
75
84
  ) : null;
76
85
  const editButton = hasEditAction ? (
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+
3
+ const DEFAULT_ROW_ACTIONS_WIDTH = 96;
4
+
5
+ const isReactComponentType = value =>
6
+ value &&
7
+ typeof value === 'object' &&
8
+ typeof value.$$typeof === 'symbol';
9
+
10
+ export const hasDefaultTableRowActionsComponent = component =>
11
+ typeof component === 'function' ||
12
+ React.isValidElement(component) ||
13
+ isReactComponentType(component);
14
+
15
+ export const resolveDefaultTableRowActionsWidth = configs => {
16
+ const rawWidth = configs?.rowActionsWidth ?? configs?.actionsColumnWidth;
17
+ const width = Number(rawWidth);
18
+
19
+ if (Number.isFinite(width) && width > 0) {
20
+ return width;
21
+ }
22
+
23
+ return DEFAULT_ROW_ACTIONS_WIDTH;
24
+ };
25
+
26
+ const DefaultTableRowActions = ({ component, ...props }) => {
27
+ if (React.isValidElement(component)) {
28
+ return React.cloneElement(component, {
29
+ ...(component.props || {}),
30
+ ...props,
31
+ });
32
+ }
33
+
34
+ if (hasDefaultTableRowActionsComponent(component)) {
35
+ return React.createElement(component, props);
36
+ }
37
+
38
+ return null;
39
+ };
40
+
41
+ export default DefaultTableRowActions;
@@ -5,8 +5,12 @@ import { useStore } from '@store';
5
5
  import { formatStoreColumnLabel } from '@controleonline/ui-common/src/react/utils/storeColumns';
6
6
  import { getColumnKey } from '../inputs/defaultInputUtils';
7
7
  import DefaultColumnFilter from '../filters/DefaultColumnFilter';
8
- import DefaultTableEmptyState from './DefaultTableEmptyState';
9
- import DefaultTableInput from './DefaultTableInput';
8
+ import DefaultTableEmptyState from './DefaultTableEmptyState';
9
+ import DefaultTableInput from './DefaultTableInput';
10
+ import DefaultTableRowActions, {
11
+ hasDefaultTableRowActionsComponent,
12
+ resolveDefaultTableRowActionsWidth,
13
+ } from './DefaultTableRowActions';
10
14
  import {
11
15
  END_REACHED_THRESHOLD,
12
16
  getColumnStyle,
@@ -48,14 +52,14 @@ const DefaultTableRows = ({ storeName }) => {
48
52
  const tableTextColor = palette.text;
49
53
  const rowStyle = configs.rowStyle;
50
54
  const hasRowPress = typeof configs.onRowPress === 'function';
51
- const hasCustomRowActions = typeof configs.rowActionsComponent === 'function';
52
- const hasEditAction = typeof configs.onEditRow === 'function';
53
- const hasRowActions = configs.showRowActions !== false && (hasCustomRowActions || hasEditAction);
55
+ const hasCustomRowActions = hasDefaultTableRowActionsComponent(configs.rowActionsComponent);
56
+ const hasEditAction = typeof configs.onEditRow === 'function';
57
+ const hasRowActions = configs.showRowActions !== false && (hasCustomRowActions || hasEditAction);
54
58
  const shouldPinRowActions = configs.pinRowActions !== false;
55
59
  const hasColumnFilters =
56
60
  configs.showColumnFiltersButton !== false &&
57
61
  configs.tableFiltersVisible === true;
58
- const actionsCellWidth = hasRowActions ? 96 : 0;
62
+ const actionsCellWidth = hasRowActions ? resolveDefaultTableRowActionsWidth(configs) : 0;
59
63
  const [horizontalMetrics, setHorizontalMetrics] = useState({
60
64
  contentWidth: 0,
61
65
  viewportWidth: 0,
@@ -179,13 +183,19 @@ const DefaultTableRows = ({ storeName }) => {
179
183
  ]}
180
184
  >
181
185
  <View style={styles.rowActionsGroup}>
182
- {hasCustomRowActions ? (
183
- <RowActionsComponent
184
- openEdit={() => configs.onEditRow?.(row)}
185
- openRow={hasRowPress ? () => configs.onRowPress(row) : null}
186
- row={row}
187
- />
188
- ) : null}
186
+ {hasCustomRowActions ? (
187
+ <DefaultTableRowActions
188
+ component={RowActionsComponent}
189
+ helpers={{
190
+ openEdit: () => configs.onEditRow?.(row),
191
+ openRow: hasRowPress ? () => configs.onRowPress(row) : null,
192
+ }}
193
+ openEdit={() => configs.onEditRow?.(row)}
194
+ openRow={hasRowPress ? () => configs.onRowPress(row) : null}
195
+ row={row}
196
+ storeName={storeName}
197
+ />
198
+ ) : null}
189
199
  {hasEditAction ? (
190
200
  <TouchableOpacity
191
201
  style={[styles.iconButton, { borderColor: tableBorderColor, backgroundColor: tableSurfaceColor }]}
@@ -182,4 +182,76 @@ describe('DefaultSelect', () => {
182
182
  }),
183
183
  });
184
184
  });
185
+
186
+ it('keeps the status label visible when the row status has color but no icon', async () => {
187
+ global.t = {
188
+ t: jest.fn(() => 'Waiting payment'),
189
+ };
190
+ let tree;
191
+
192
+ await renderer.act(async () => {
193
+ tree = renderer.create(
194
+ React.createElement(DefaultSelect, {
195
+ column: {
196
+ name: 'status',
197
+ label: 'Status',
198
+ list: 'status/getItems',
199
+ translate: false,
200
+ format(value) {
201
+ return {
202
+ value: value.id,
203
+ label: global.t.t('orders', 'status', value.status),
204
+ color: value.color,
205
+ };
206
+ },
207
+ formatList(value) {
208
+ return {
209
+ value: value.id,
210
+ label: global.t.t('orders', 'status', value.status),
211
+ color: value.color,
212
+ };
213
+ },
214
+ },
215
+ row: {
216
+ status: {
217
+ id: 6,
218
+ status: 'waiting payment',
219
+ color: '#000000',
220
+ },
221
+ },
222
+ storeName: 'orders',
223
+ }),
224
+ );
225
+ });
226
+
227
+ const visibleStatus = tree.root
228
+ .findAllByType('Text')
229
+ .find(node => node.props.children === 'Waiting payment');
230
+
231
+ expect(visibleStatus).toBeTruthy();
232
+ expect(visibleStatus.props.style).toEqual(
233
+ expect.arrayContaining([
234
+ expect.objectContaining({
235
+ flex: 1,
236
+ fontSize: 13,
237
+ fontWeight: '600',
238
+ }),
239
+ {color: '#000000'},
240
+ ]),
241
+ );
242
+ expect(visibleStatus.props.style).not.toEqual(
243
+ expect.arrayContaining([
244
+ expect.objectContaining({
245
+ backgroundColor: expect.any(String),
246
+ }),
247
+ ]),
248
+ );
249
+ expect(visibleStatus.props.style).not.toEqual(
250
+ expect.arrayContaining([
251
+ expect.objectContaining({
252
+ borderColor: expect.any(String),
253
+ }),
254
+ ]),
255
+ );
256
+ });
185
257
  });
@@ -401,6 +401,61 @@ describe('DefaultTable', () => {
401
401
  );
402
402
  });
403
403
 
404
+ it('renders a custom row actions component with the configured actions width', () => {
405
+ mockWindowDimensions = {width: 1200, height: 800};
406
+ mockStores.orders = {
407
+ actions: {},
408
+ getters: {
409
+ columns: [
410
+ {key: 'id', label: 'ID', isIdentity: true},
411
+ {key: 'name', label: 'Nome'},
412
+ ],
413
+ items: [{id: 12, name: 'Pedido'}],
414
+ visibleColumns: {},
415
+ },
416
+ };
417
+ const rowActionsComponent = props =>
418
+ React.createElement('row-actions', props, props.row.name);
419
+ let tree;
420
+
421
+ renderer.act(() => {
422
+ tree = renderer.create(
423
+ React.createElement(DefaultTable, {
424
+ onEditRow: jest.fn(),
425
+ rowActionsComponent,
426
+ rowActionsWidth: 180,
427
+ storeName: 'orders',
428
+ }),
429
+ );
430
+ });
431
+
432
+ const rowActions = tree.root.findByType('row-actions');
433
+ expect(rowActions.props.row).toEqual({id: 12, name: 'Pedido'});
434
+ expect(rowActions.props.storeName).toBe('orders');
435
+ expect(typeof rowActions.props.openEdit).toBe('function');
436
+ expect(rowActions.props.helpers).toEqual(
437
+ expect.objectContaining({
438
+ openEdit: expect.any(Function),
439
+ }),
440
+ );
441
+
442
+ const renderedStyles = [
443
+ ...tree.root.findAllByType('View'),
444
+ ...tree.root.findAllByType('TouchableOpacity'),
445
+ ].map(node => flattenStyle(node.props.style));
446
+
447
+ expect(renderedStyles).toEqual(
448
+ expect.arrayContaining([
449
+ expect.objectContaining({
450
+ flexBasis: 180,
451
+ maxWidth: 180,
452
+ minWidth: 180,
453
+ width: 180,
454
+ }),
455
+ ]),
456
+ );
457
+ });
458
+
404
459
  it('keeps only the identity column pinned when row actions are not pinned', () => {
405
460
  mockWindowDimensions = {width: 1200, height: 800};
406
461
  mockStores.orders = {
@@ -0,0 +1,37 @@
1
+ const {describe, expect, it} = require('@jest/globals');
2
+
3
+ jest.mock('@store', () => ({
4
+ getAllStores: () => ({}),
5
+ }));
6
+
7
+ jest.mock('react-native', () => ({
8
+ Platform: {
9
+ select: value => value.web || value.default,
10
+ },
11
+ StyleSheet: {
12
+ create: value => value,
13
+ },
14
+ }));
15
+
16
+ jest.mock('@controleonline/ui-common/src/utils/formatter.js', () => ({}));
17
+ jest.mock('@controleonline/ui-common/src/react/utils/dateRangeFilter', () => ({
18
+ getDateRange: () => ({}),
19
+ }));
20
+
21
+ const {
22
+ getColumnMinWidth,
23
+ getColumnStyle,
24
+ } = require('../../../../react/components/table/DefaultTable.utils');
25
+
26
+ describe('DefaultTable utils', () => {
27
+ it('uses column width metadata from the store configuration', () => {
28
+ expect(getColumnMinWidth({name: 'status', minWidth: 180})).toBe(180);
29
+ expect(getColumnStyle({name: 'status', minWidth: 180})).toEqual(
30
+ expect.arrayContaining([
31
+ expect.objectContaining({
32
+ minWidth: 180,
33
+ }),
34
+ ]),
35
+ );
36
+ });
37
+ });
@@ -1,8 +1,8 @@
1
- const {describe, expect, it, jest} = require('@jest/globals');
2
-
3
- jest.mock('@store', () => ({
4
- getAllStores: () => ({}),
5
- }));
1
+ const {describe, expect, it, jest} = require('@jest/globals');
2
+
3
+ jest.mock('@store', () => ({
4
+ getAllStores: () => ({}),
5
+ }));
6
6
 
7
7
  jest.mock('@controleonline/ui-common/src/utils/formatter.js', () => ({}));
8
8
 
@@ -16,7 +16,7 @@ const {
16
16
  resolveCellPresentation,
17
17
  } = require('../../../react/components/inputs/defaultInputUtils');
18
18
 
19
- describe('defaultInputUtils', () => {
19
+ describe('defaultInputUtils', () => {
20
20
  it('deduplicates list options by normalized key', () => {
21
21
  const column = {
22
22
  formatList(item) {
@@ -100,6 +100,38 @@ describe('defaultInputUtils', () => {
100
100
  });
101
101
  });
102
102
 
103
+ it('resolves table cell presentation from the column format object', () => {
104
+ const presentation = resolveCellPresentation({
105
+ column: {
106
+ name: 'status',
107
+ format(value) {
108
+ return {
109
+ label: value.status,
110
+ color: value.color,
111
+ icon: value.icon,
112
+ };
113
+ },
114
+ },
115
+ row: {
116
+ status: {
117
+ status: 'Preparando',
118
+ color: '#0EA5E9',
119
+ icon: 'clock',
120
+ },
121
+ },
122
+ storeName: 'orders',
123
+ });
124
+
125
+ expect(presentation).toEqual({
126
+ backgroundColor: undefined,
127
+ borderColor: undefined,
128
+ color: '#0EA5E9',
129
+ icon: 'clock',
130
+ image: undefined,
131
+ label: 'Preparando',
132
+ });
133
+ });
134
+
103
135
  it('builds subtle badge colors from resource colors', () => {
104
136
  expect(buildReadPresentationStyles({
105
137
  color: '#16A34A',