@controleonline/ui-common 1.2.78 → 1.2.80

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.78",
3
+ "version": "1.2.80",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Common Quasar UI Component",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@ import {
64
64
  resolvePosOperationMode,
65
65
  resolvePosPrintMode,
66
66
  } from '@controleonline/ui-common/src/react/config/deviceConfigBootstrap';
67
+ import { buildDeviceAliasStoreUpdates } from '@controleonline/ui-common/src/react/utils/deviceAliasSync';
67
68
 
68
69
  import {
69
70
  filterDeviceConfigsByCompany,
@@ -496,11 +497,16 @@ const DeviceDetailPage = () => {
496
497
  const [savingAlias, setSavingAlias] = useState(false);
497
498
  const [removingDevice, setRemovingDevice] = useState(false);
498
499
  const aliasInputRef = useRef(null);
500
+ const skipAliasSyncFromStoreRef = useRef(false);
499
501
 
500
502
  useEffect(() => {
501
503
  if (editingAlias) {
502
504
  return;
503
505
  }
506
+ if (skipAliasSyncFromStoreRef.current) {
507
+ skipAliasSyncFromStoreRef.current = false;
508
+ return;
509
+ }
504
510
 
505
511
  setAlias(initialAlias || '');
506
512
  setAliasInput(initialAlias || '');
@@ -1060,6 +1066,19 @@ const DeviceDetailPage = () => {
1060
1066
  alias: trimmed,
1061
1067
  });
1062
1068
  const nextAlias = String(savedDevice?.alias || trimmed).trim();
1069
+ const { mergedDevice, nextDeviceConfig } = buildDeviceAliasStoreUpdates({
1070
+ deviceId,
1071
+ nextAlias,
1072
+ runtimeDevice,
1073
+ runtimeDeviceConfig,
1074
+ savedDevice,
1075
+ normalizeEntityId,
1076
+ });
1077
+ actionsRef.current.deviceActions.setItem?.(mergedDevice);
1078
+ if (nextDeviceConfig && actionsRef.current.deviceConfigActions?.setItem) {
1079
+ actionsRef.current.deviceConfigActions.setItem(nextDeviceConfig);
1080
+ }
1081
+ skipAliasSyncFromStoreRef.current = true;
1063
1082
  setAlias(nextAlias);
1064
1083
  setAliasInput(nextAlias);
1065
1084
  setEditingAlias(false);
@@ -1075,28 +1094,25 @@ const DeviceDetailPage = () => {
1075
1094
  if (!deviceId || removingDevice) {
1076
1095
  return;
1077
1096
  }
1078
- Alert.alert(
1079
- 'Excluir device',
1080
- `Tem certeza que deseja excluir o device "${alias || deviceString || deviceId}"? Esta ação não pode ser desfeita.`,
1081
- [
1082
- { text: 'Cancelar', style: 'cancel' },
1083
- {
1084
- text: 'Excluir',
1085
- style: 'destructive',
1086
- onPress: async () => {
1087
- setRemovingDevice(true);
1088
- try {
1089
- await actionsRef.current.deviceActions.remove(deviceId);
1090
- navigation.navigate('DevicesIndex');
1091
- } catch (error) {
1092
- showSystemError(error, 'Não foi possível excluir o device.');
1093
- } finally {
1094
- setRemovingDevice(false);
1095
- }
1096
- },
1097
- },
1098
- ],
1099
- );
1097
+ const label = String(alias || deviceString || deviceId).trim();
1098
+ const message = `Tem certeza que deseja excluir o device "${label}"? Esta ação não pode ser desfeita.`;
1099
+ // Use web-safe confirm() (window.confirm on web; Alert.alert on native).
1100
+ // Alert.alert alone is a no-op on Manager web — root cause of "click does nothing".
1101
+ confirm(message, async () => {
1102
+ setRemovingDevice(true);
1103
+ try {
1104
+ await actionsRef.current.deviceActions.remove(deviceId);
1105
+ if (navigation?.canGoBack?.()) {
1106
+ navigation.goBack();
1107
+ } else {
1108
+ navigation.navigate('DevicesIndex');
1109
+ }
1110
+ } catch (error) {
1111
+ showSystemError(error, 'Não foi possível excluir o device.');
1112
+ } finally {
1113
+ setRemovingDevice(false);
1114
+ }
1115
+ });
1100
1116
  }, [deviceId, removingDevice, alias, deviceString, navigation, showSystemError]);
1101
1117
 
1102
1118
  const saveDevicePaymentTarget = useCallback(async (override = {}) => {
@@ -1934,6 +1950,7 @@ const DeviceDetailPage = () => {
1934
1950
  activeOpacity={0.8}
1935
1951
  disabled={removingDevice || savingAlias}
1936
1952
  accessibilityLabel="Excluir device"
1953
+ testID="device-detail-delete-btn"
1937
1954
  >
1938
1955
  <Icon
1939
1956
  name={removingDevice ? 'loader' : 'trash-2'}
@@ -26,8 +26,44 @@ import {
26
26
  } from './deviceListHelpers';
27
27
  import styles from '../../Devices.styles';
28
28
 
29
+ /**
30
+ * Normalize DefaultTable card `item` into a device group shape.
31
+ * DefaultTable can hand us either a grouped object ({device, deviceConfigs})
32
+ * or a flat device_config entity from the store (no deviceConfigs array).
33
+ * Accessing `.deviceConfigs[0]` on a flat item throws TypeError (app-community#627 KDS).
34
+ */
35
+ const normalizeDeviceGroup = item => {
36
+ if (!item || typeof item !== 'object') {
37
+ return null;
38
+ }
39
+
40
+ if (Array.isArray(item.deviceConfigs)) {
41
+ return item;
42
+ }
43
+
44
+ // Flat device_config row from store — promote to single-config group.
45
+ if (item.device != null || item.type != null || item.id != null) {
46
+ return {
47
+ key:
48
+ item.key ||
49
+ item['@id'] ||
50
+ (item.id != null ? `config:${item.id}` : undefined),
51
+ device: item.device && typeof item.device === 'object' ? item.device : {},
52
+ deviceConfigs: [item],
53
+ primaryConfig: item,
54
+ alias:
55
+ item.alias ||
56
+ item.device?.alias ||
57
+ item.device?.device ||
58
+ undefined,
59
+ };
60
+ }
61
+
62
+ return null;
63
+ };
64
+
29
65
  export default function DeviceGroupCard({
30
- item: deviceGroup,
66
+ item,
31
67
  brandColors,
32
68
  creatingPdv,
33
69
  goToDetail,
@@ -38,6 +74,13 @@ export default function DeviceGroupCard({
38
74
  runtimeDeviceType,
39
75
  showCurrentPdvSetup,
40
76
  }) {
77
+ const deviceGroup = normalizeDeviceGroup(item);
78
+ if (!deviceGroup) {
79
+ return null;
80
+ }
81
+ const deviceConfigs = Array.isArray(deviceGroup.deviceConfigs)
82
+ ? deviceGroup.deviceConfigs
83
+ : [];
41
84
  const isCurrentDevice = isCurrentDeviceGroup({
42
85
  deviceGroup,
43
86
  runtimeDeviceIdentifier,
@@ -52,7 +95,8 @@ export default function DeviceGroupCard({
52
95
  const primaryConfig =
53
96
  sessionConfig ||
54
97
  filteredTypeConfig ||
55
- deviceGroup.deviceConfigs[0] ||
98
+ deviceGroup.primaryConfig ||
99
+ deviceConfigs[0] ||
56
100
  null;
57
101
  const normalizedType = primaryConfig
58
102
  ? getDeviceConfigType(primaryConfig)
@@ -150,7 +194,7 @@ export default function DeviceGroupCard({
150
194
  </Text>
151
195
 
152
196
  <View style={styles.deviceConfigRow}>
153
- {deviceGroup.deviceConfigs.map(deviceConfig => {
197
+ {deviceConfigs.map(deviceConfig => {
154
198
  const configType = getDeviceConfigType(deviceConfig);
155
199
  const configAccent = getDeviceTypeAccent(configType);
156
200
  const isSessionConfig =
@@ -138,6 +138,12 @@ export const getDeviceListIdentifier = deviceConfig =>
138
138
  deviceConfig,
139
139
  }).runtimeDetail || String(deviceConfig?.device?.device || '').trim();
140
140
 
141
+ /**
142
+ * Build list params for device_configs collection.
143
+ * Always send a single scalar `type` when filtering (API SearchFilter exact).
144
+ * Callers with multiple queryTypes must request each type separately and merge.
145
+ * Empty queryTypes = no type filter (All).
146
+ */
141
147
  export const buildDeviceListParams = ({
142
148
  companyId,
143
149
  page,
@@ -151,17 +157,43 @@ export const buildDeviceListParams = ({
151
157
  'order[id]': 'DESC',
152
158
  };
153
159
 
154
- if (Array.isArray(queryTypes) && queryTypes.length === 1) {
155
- params.type = queryTypes[0];
156
- }
160
+ const types = Array.isArray(queryTypes)
161
+ ? queryTypes.map(t => String(t || '').trim()).filter(Boolean)
162
+ : [];
157
163
 
158
- if (Array.isArray(queryTypes) && queryTypes.length > 1) {
159
- params.type = queryTypes;
164
+ if (types.length === 1) {
165
+ params.type = types[0];
160
166
  }
161
167
 
168
+ // Multi-type: do NOT set params.type as array — API exact filter is unreliable
169
+ // with type[]= on some platforms; callers must fetch each type and merge.
170
+
162
171
  return params;
163
172
  };
164
173
 
174
+ /**
175
+ * Expand queryTypes into one or more single-type param sets for sequential fetch.
176
+ * Empty → one set without type (All). Single → one set. Multiple → one set per type.
177
+ */
178
+ export const expandDeviceListParamSets = ({
179
+ companyId,
180
+ page,
181
+ pageSize = PAGE_SIZE,
182
+ queryTypes = [],
183
+ }) => {
184
+ const types = Array.isArray(queryTypes)
185
+ ? queryTypes.map(t => String(t || '').trim()).filter(Boolean)
186
+ : [];
187
+
188
+ if (types.length === 0) {
189
+ return [buildDeviceListParams({companyId, page, pageSize, queryTypes: []})];
190
+ }
191
+
192
+ return types.map(type =>
193
+ buildDeviceListParams({companyId, page, pageSize, queryTypes: [type]}),
194
+ );
195
+ };
196
+
165
197
  export {
166
198
  parseConfigsObject,
167
199
  getDeviceConfigType,
@@ -55,7 +55,7 @@ import {
55
55
  getPosOperationModeLabel,
56
56
  getDeviceDetailRoute,
57
57
  getDeviceListIdentifier,
58
- buildDeviceListParams,
58
+ expandDeviceListParamSets,
59
59
  isPdvPrinterEnabled,
60
60
  } from './deviceListHelpers';
61
61
 
@@ -205,40 +205,45 @@ export const createDeviceTypeTab = ({
205
205
 
206
206
  try {
207
207
  const requestPageSize = Math.max(pageSize, API_PAGE_SIZE);
208
- let page = 1;
209
208
  let loadedItems = [];
210
209
  let reportedTotal = 0;
211
210
 
212
- while (true) {
213
- const pageItems = await deviceConfigStore.actions.getItems(
214
- buildDeviceListParams({
215
- companyId,
211
+ const paramSets = expandDeviceListParamSets({
212
+ companyId,
213
+ page: 1,
214
+ pageSize: requestPageSize,
215
+ queryTypes,
216
+ });
217
+
218
+ for (const baseParams of paramSets) {
219
+ let page = 1;
220
+ while (true) {
221
+ const pageItems = await deviceConfigStore.actions.getItems({
222
+ ...baseParams,
216
223
  page,
217
- pageSize: requestPageSize,
218
- queryTypes,
219
- }),
220
- );
221
- const previousLength = loadedItems.length;
222
- loadedItems = mergeDeviceConfigs(loadedItems, pageItems);
223
- reportedTotal = Math.max(
224
- reportedTotal,
225
- Number(
226
- deviceConfigStore.getters.totalItems ||
227
- loadedItems.length ||
228
- 0,
229
- ),
230
- );
231
-
232
- if (
233
- !Array.isArray(pageItems) ||
234
- pageItems.length === 0 ||
235
- loadedItems.length >= reportedTotal ||
236
- loadedItems.length === previousLength
237
- ) {
238
- break;
224
+ });
225
+ const previousLength = loadedItems.length;
226
+ loadedItems = mergeDeviceConfigs(loadedItems, pageItems);
227
+ reportedTotal = Math.max(
228
+ reportedTotal,
229
+ Number(
230
+ deviceConfigStore.getters.totalItems ||
231
+ loadedItems.length ||
232
+ 0,
233
+ ),
234
+ );
235
+
236
+ if (
237
+ !Array.isArray(pageItems) ||
238
+ pageItems.length === 0 ||
239
+ loadedItems.length >= reportedTotal ||
240
+ loadedItems.length === previousLength
241
+ ) {
242
+ break;
243
+ }
244
+
245
+ page += 1;
239
246
  }
240
-
241
- page += 1;
242
247
  }
243
248
 
244
249
  setDeviceConfigs(loadedItems);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Build device + device_config store payloads after a successful alias save.
3
+ * Keeps derived initialAlias in sync so UI does not snap back to the old name.
4
+ */
5
+ export function buildDeviceAliasStoreUpdates({
6
+ deviceId,
7
+ nextAlias,
8
+ runtimeDevice,
9
+ runtimeDeviceConfig,
10
+ savedDevice,
11
+ normalizeEntityId,
12
+ }) {
13
+ const id = normalizeEntityId(deviceId);
14
+ const alias = String(nextAlias || '').trim();
15
+
16
+ const baseDevice =
17
+ runtimeDevice &&
18
+ normalizeEntityId(runtimeDevice?.id || runtimeDevice?.['@id']) === id
19
+ ? runtimeDevice
20
+ : {};
21
+
22
+ const mergedDevice = {
23
+ ...baseDevice,
24
+ ...(savedDevice && typeof savedDevice === 'object' ? savedDevice : {}),
25
+ id,
26
+ alias,
27
+ };
28
+
29
+ let nextDeviceConfig = null;
30
+ if (runtimeDeviceConfig && typeof runtimeDeviceConfig === 'object') {
31
+ const nestedDevice =
32
+ runtimeDeviceConfig.device && typeof runtimeDeviceConfig.device === 'object'
33
+ ? runtimeDeviceConfig.device
34
+ : {};
35
+ nextDeviceConfig = {
36
+ ...runtimeDeviceConfig,
37
+ device: {
38
+ ...nestedDevice,
39
+ id: nestedDevice.id || id,
40
+ alias,
41
+ },
42
+ };
43
+ }
44
+
45
+ return { mergedDevice, nextDeviceConfig };
46
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Canonical password policy shared by create / change / reset flows.
3
+ * Must stay aligned with api-platform-users PasswordPolicyService.
4
+ */
5
+ export const PASSWORD_MIN_LENGTH = 6;
6
+
7
+ export const PASSWORD_MSG_REQUIRED = 'A senha é obrigatória.';
8
+ export const PASSWORD_MSG_MIN_LENGTH = `A senha precisa ter pelo menos ${PASSWORD_MIN_LENGTH} caracteres.`;
9
+ export const PASSWORD_MSG_COMPROMISED =
10
+ 'Esta senha não pode ser usada porque consta em lista de senhas comprometidas (vazamento de dados). Escolha outra senha.';
11
+ export const PASSWORD_MSG_CONFIRM_MISMATCH =
12
+ 'A senha e a confirmação devem ser iguais.';
13
+
14
+ export const PASSWORD_HELP_LINES = [
15
+ `Mínimo de ${PASSWORD_MIN_LENGTH} caracteres.`,
16
+ 'Não use senhas que já tenham vazado em vazamentos públicos de dados.',
17
+ ];
18
+
19
+ /**
20
+ * Client-side validation for length/required/confirm.
21
+ * Does not call breach API — that remains server-side.
22
+ * @returns {string|null} error message or null when valid
23
+ */
24
+ export function validatePasswordClient(password, confirm) {
25
+ const normalized = String(password ?? '').trim();
26
+ if (!normalized) {
27
+ return PASSWORD_MSG_REQUIRED;
28
+ }
29
+ if (normalized.length < PASSWORD_MIN_LENGTH) {
30
+ return PASSWORD_MSG_MIN_LENGTH;
31
+ }
32
+ if (confirm !== undefined && confirm !== null) {
33
+ const confirmNormalized = String(confirm ?? '').trim();
34
+ if (normalized !== confirmNormalized) {
35
+ return PASSWORD_MSG_CONFIRM_MISMATCH;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Map API / raw error text to product Portuguese copy for known policy violations.
43
+ */
44
+ export function mapPasswordErrorMessage(raw) {
45
+ const text = String(raw ?? '').trim();
46
+ if (!text) {
47
+ return text;
48
+ }
49
+ if (/leaked|data breach|compromised password|have i been pwned|comprometid/i.test(text)) {
50
+ return PASSWORD_MSG_COMPROMISED;
51
+ }
52
+ const minMatch =
53
+ text.match(/at least\s+(\d+)\s+characters/i) ||
54
+ text.match(/pelo menos\s+(\d+)\s+caracteres/i);
55
+ if (minMatch) {
56
+ return `A senha precisa ter pelo menos ${minMatch[1]} caracteres.`;
57
+ }
58
+ if (/must be identical|não coincidem|must match/i.test(text)) {
59
+ return PASSWORD_MSG_CONFIRM_MISMATCH;
60
+ }
61
+ return text;
62
+ }
63
+
64
+ export default {
65
+ PASSWORD_MIN_LENGTH,
66
+ PASSWORD_MSG_REQUIRED,
67
+ PASSWORD_MSG_MIN_LENGTH,
68
+ PASSWORD_MSG_COMPROMISED,
69
+ PASSWORD_MSG_CONFIRM_MISMATCH,
70
+ PASSWORD_HELP_LINES,
71
+ validatePasswordClient,
72
+ mapPasswordErrorMessage,
73
+ };
@@ -1,6 +1,6 @@
1
- import { getDeviceTypeLabel } from '@controleonline/ui-common/src/react/utils/printerDevices';
2
-
3
1
  const DEVICE_RUNTIME_FOOTER_TEXT_CONFIG_KEY = 'device-runtime-footer-text';
2
+ const PRINTER_DEVICE_TYPES = new Set(['PRINT', 'PRINTER']);
3
+ const IP_CAMERA_DEVICE_TYPE = 'IP_CAMERA';
4
4
 
5
5
  const safeTrim = value => String(value || '').trim();
6
6
 
@@ -25,6 +25,20 @@ const normalizeComparableText = value =>
25
25
  .normalize('NFD')
26
26
  .replace(/[\u0300-\u036f]/g, '');
27
27
 
28
+ const getRuntimeFooterDeviceTypeLabel = type => {
29
+ const normalizedType = safeTrim(type).toUpperCase();
30
+
31
+ if (PRINTER_DEVICE_TYPES.has(normalizedType)) {
32
+ return 'Impressora';
33
+ }
34
+
35
+ if (normalizedType === IP_CAMERA_DEVICE_TYPE) {
36
+ return 'Camera IP';
37
+ }
38
+
39
+ return normalizedType || 'DEVICE';
40
+ };
41
+
28
42
  const getRuntimeFooterTranslation = (store, type, key) =>
29
43
  safeTrim(globalThis?.t?.getMessageFromBuckets?.(store, type, key));
30
44
 
@@ -254,7 +268,7 @@ const getRuntimeFooterOperationalTypeLabel = ({device, deviceConfig} = {}) => {
254
268
  });
255
269
 
256
270
  return operationalTypeInfo.value
257
- ? getDeviceTypeLabel(operationalTypeInfo.value)
271
+ ? getRuntimeFooterDeviceTypeLabel(operationalTypeInfo.value)
258
272
  : '';
259
273
  };
260
274
 
@@ -382,7 +396,7 @@ const getRuntimeFooterDebugInfo = ({device, appVersion, deviceConfig}) => {
382
396
  deviceConfig,
383
397
  });
384
398
  const operationalTypeLabel = operationalTypeInfo.value
385
- ? getDeviceTypeLabel(operationalTypeInfo.value)
399
+ ? getRuntimeFooterDeviceTypeLabel(operationalTypeInfo.value)
386
400
  : '';
387
401
  const operationModeLabel =
388
402
  operationalTypeLabel === 'PDV'
@@ -0,0 +1,322 @@
1
+ const {expect, test} = require('playwright/test');
2
+ const packageJson = require('../../../../../../../package.json');
3
+ const {API_ORIGIN} = require('../../../../../../../src/tests/browser/apiOrigin');
4
+
5
+ const APP_VERSION = packageJson?.version || '1.0.0';
6
+ const CURRENT_DEVICE_ID = 'web-7';
7
+
8
+ const CORS_HEADERS = {
9
+ 'access-control-allow-origin': '*',
10
+ 'access-control-allow-headers':
11
+ 'API-TOKEN, APP-DOMAIN, DEVICE, ACCEPT, CONTENT-TYPE, X-Requested-With',
12
+ 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
13
+ };
14
+
15
+ const jsonHeaders = () => ({
16
+ ...CORS_HEADERS,
17
+ 'content-type': 'application/ld+json; charset=utf-8',
18
+ });
19
+
20
+ const collection = member => ({
21
+ member,
22
+ 'hydra:member': member,
23
+ totalItems: member.length,
24
+ 'hydra:totalItems': member.length,
25
+ summary: {},
26
+ });
27
+
28
+ const currentDevice = {
29
+ '@id': '/devices/396',
30
+ '@type': 'Device',
31
+ id: 396,
32
+ device: CURRENT_DEVICE_ID,
33
+ alias: 'Caixa atual',
34
+ metadata: {
35
+ runtime: 'web',
36
+ network: {publicIp: '127.0.0.1'},
37
+ },
38
+ };
39
+
40
+ const printerDevice = {
41
+ '@id': '/devices/501',
42
+ '@type': 'Device',
43
+ id: 501,
44
+ device: 'printer-1',
45
+ alias: 'Impressora cozinha',
46
+ metadata: {runtime: 'network'},
47
+ };
48
+
49
+ const displayDevice = {
50
+ '@id': '/devices/502',
51
+ '@type': 'Device',
52
+ id: 502,
53
+ device: 'kds-1',
54
+ alias: 'KDS salão',
55
+ metadata: {runtime: 'network'},
56
+ };
57
+
58
+ const createDeviceConfig = ({id, type, device = currentDevice, alias}) => ({
59
+ '@id': `/device_configs/${id}`,
60
+ '@type': 'DeviceConfig',
61
+ id,
62
+ type,
63
+ people: '/people/3',
64
+ device: alias
65
+ ? {...device, alias}
66
+ : device,
67
+ configs: JSON.stringify({
68
+ 'config-version': APP_VERSION,
69
+ 'pos-gateway': 'infinite-pay',
70
+ }),
71
+ });
72
+
73
+ /**
74
+ * Smoke for app-community#381:
75
+ * type filters beyond All/PDV must not throw and must request scalar `type`
76
+ * (or omit type for All). Multi-type filters expand to sequential single-type GETs.
77
+ */
78
+ const mockDevicesTypeFilterApi = async page => {
79
+ const company = {
80
+ id: 3,
81
+ name: 'Teste',
82
+ alias: 'TESTE',
83
+ panel_enabled: true,
84
+ enabled: true,
85
+ commercial_enabled: true,
86
+ theme: {
87
+ colors: {
88
+ primary: '#0EA5E9',
89
+ cardBackground: '#FFFFFF',
90
+ cardBorder: '#D8E0EA',
91
+ cardSelectedBackground: '#E8F8FD',
92
+ cardSelectedBorder: '#0284C7',
93
+ cardSelectedText: '#0F172A',
94
+ badgeSelectedBackground: '#CDEFFA',
95
+ badgeSelectedText: '#075985',
96
+ },
97
+ },
98
+ };
99
+
100
+ const allConfigs = [
101
+ createDeviceConfig({id: 487, type: 'MANAGER'}),
102
+ createDeviceConfig({id: 488, type: 'PDV'}),
103
+ createDeviceConfig({
104
+ id: 501,
105
+ type: 'PRINTER',
106
+ device: printerDevice,
107
+ }),
108
+ createDeviceConfig({
109
+ id: 502,
110
+ type: 'DISPLAY',
111
+ device: displayDevice,
112
+ }),
113
+ createDeviceConfig({id: 503, type: 'DEVICE'}),
114
+ ];
115
+
116
+ const typeRequests = [];
117
+
118
+ await page.route(`${API_ORIGIN}/**`, async route => {
119
+ const request = route.request();
120
+ const url = new URL(request.url());
121
+ const pathname = url.pathname.replace(/^\/+/, '');
122
+ const method = request.method().toUpperCase();
123
+
124
+ if (method === 'OPTIONS') {
125
+ return route.fulfill({status: 204, headers: CORS_HEADERS, body: ''});
126
+ }
127
+
128
+ if (pathname === 'themes-colors.css') {
129
+ return route.fulfill({
130
+ status: 200,
131
+ headers: {...CORS_HEADERS, 'content-type': 'text/css; charset=utf-8'},
132
+ body: ':root { --primary: #0ea5e9; }',
133
+ });
134
+ }
135
+
136
+ if (pathname === 'runtime/ip') {
137
+ return route.fulfill({
138
+ status: 200,
139
+ headers: jsonHeaders(),
140
+ body: JSON.stringify({ip: '127.0.0.1'}),
141
+ });
142
+ }
143
+
144
+ if (pathname === 'menus-people') {
145
+ return route.fulfill({
146
+ status: 200,
147
+ headers: jsonHeaders(),
148
+ body: JSON.stringify({modules: {}}),
149
+ });
150
+ }
151
+
152
+ if (pathname === 'people/companies/my') {
153
+ return route.fulfill({
154
+ status: 200,
155
+ headers: jsonHeaders(),
156
+ body: JSON.stringify(collection([company])),
157
+ });
158
+ }
159
+
160
+ if (pathname === 'people/company/default') {
161
+ return route.fulfill({
162
+ status: 200,
163
+ headers: jsonHeaders(),
164
+ body: JSON.stringify(company),
165
+ });
166
+ }
167
+
168
+ if (pathname === 'configs/discovery-configs') {
169
+ return route.fulfill({
170
+ status: 200,
171
+ headers: jsonHeaders(),
172
+ body: JSON.stringify({configs: {}}),
173
+ });
174
+ }
175
+
176
+ if (pathname === 'devices' && method === 'GET') {
177
+ return route.fulfill({
178
+ status: 200,
179
+ headers: jsonHeaders(),
180
+ body: JSON.stringify(
181
+ collection([currentDevice, printerDevice, displayDevice]),
182
+ ),
183
+ });
184
+ }
185
+
186
+ if (pathname === 'devices' && method === 'POST') {
187
+ return route.fulfill({
188
+ status: 200,
189
+ headers: jsonHeaders(),
190
+ body: JSON.stringify(currentDevice),
191
+ });
192
+ }
193
+
194
+ if (pathname === 'device_configs' && method === 'GET') {
195
+ const requestedType = url.searchParams.get('type');
196
+ // API Platform array notation would be type[] — must NOT appear for this fix
197
+ const hasArrayType =
198
+ url.searchParams.has('type[]') ||
199
+ [...url.searchParams.keys()].some(k => k === 'type[]' || k.startsWith('type['));
200
+
201
+ typeRequests.push({
202
+ type: requestedType,
203
+ hasArrayType,
204
+ search: url.search,
205
+ });
206
+
207
+ if (hasArrayType) {
208
+ // Simulate the previous failure mode (bad request / empty / error)
209
+ return route.fulfill({
210
+ status: 400,
211
+ headers: jsonHeaders(),
212
+ body: JSON.stringify({
213
+ 'hydra:description': 'Invalid type filter array notation',
214
+ }),
215
+ });
216
+ }
217
+
218
+ const filtered = requestedType
219
+ ? allConfigs.filter(c => String(c.type).toUpperCase() === String(requestedType).toUpperCase())
220
+ : allConfigs;
221
+
222
+ return route.fulfill({
223
+ status: 200,
224
+ headers: jsonHeaders(),
225
+ body: JSON.stringify(collection(filtered)),
226
+ });
227
+ }
228
+
229
+ return route.fulfill({
230
+ status: 200,
231
+ headers: jsonHeaders(),
232
+ body: JSON.stringify(collection([])),
233
+ });
234
+ });
235
+
236
+ return {typeRequests};
237
+ };
238
+
239
+ test.describe('Manager devices-index type filters (device_config) #381', () => {
240
+ test('All, PDV and non-PDV types load without error (scalar type only)', async ({
241
+ page,
242
+ }) => {
243
+ const api = await mockDevicesTypeFilterApi(page);
244
+ const consoleErrors = [];
245
+ page.on('pageerror', err => consoleErrors.push(String(err)));
246
+ page.on('console', msg => {
247
+ if (msg.type() === 'error') {
248
+ consoleErrors.push(msg.text());
249
+ }
250
+ });
251
+
252
+ await page.goto('/devices-index?store=device_config');
253
+
254
+ // Initial All (or default) should render without crash
255
+ await expect(page.locator('body')).toBeVisible();
256
+ await expect.poll(() => api.typeRequests.length).toBeGreaterThan(0);
257
+
258
+ // Click filter chips by visible labels used in UI
259
+ const filterLabels = ['Todos', 'PDVs', 'KDS', 'Impressoras', 'Devices'];
260
+ for (const label of filterLabels) {
261
+ const chip = page.getByText(label, {exact: true}).first();
262
+ if (await chip.count()) {
263
+ await chip.click();
264
+ // Give list a moment to re-fetch
265
+ await page.waitForTimeout(400);
266
+ }
267
+ }
268
+
269
+ // No array-type requests (the bug)
270
+ const arrayRequests = api.typeRequests.filter(r => r.hasArrayType);
271
+ expect(arrayRequests).toEqual([]);
272
+
273
+ // At least one request without type (All) and one with scalar PDV / PRINTER / DISPLAY
274
+ const typesSeen = new Set(
275
+ api.typeRequests.map(r => r.type).filter(Boolean),
276
+ );
277
+ // PDV or PRINTER or DISPLAY should have been requested as scalar
278
+ const hasScalarNonAll = [...typesSeen].some(t =>
279
+ ['PDV', 'PRINTER', 'PRINT', 'DISPLAY', 'DEVICE', 'IP_CAMERA'].includes(
280
+ String(t).toUpperCase(),
281
+ ),
282
+ );
283
+ expect(hasScalarNonAll || api.typeRequests.some(r => !r.type)).toBe(true);
284
+
285
+ // Page still healthy
286
+ await expect(page.locator('body')).toBeVisible();
287
+ const critical = consoleErrors.filter(
288
+ e =>
289
+ !/favicon|ResizeObserver|Download the React DevTools/i.test(e) &&
290
+ /TypeError|ReferenceError|Cannot read|hydra:description|Invalid type/i.test(
291
+ e,
292
+ ),
293
+ );
294
+ expect(critical).toEqual([]);
295
+ });
296
+
297
+ test('switching All ↔ Printer ↔ All stays stable', async ({page}) => {
298
+ const api = await mockDevicesTypeFilterApi(page);
299
+
300
+ await page.goto('/devices-index?store=device_config');
301
+ await expect.poll(() => api.typeRequests.length).toBeGreaterThan(0);
302
+
303
+ const todos = page.getByText('Todos', {exact: true}).first();
304
+ const impressoras = page.getByText('Impressoras', {exact: true}).first();
305
+
306
+ if (await impressoras.count()) {
307
+ await impressoras.click();
308
+ await page.waitForTimeout(350);
309
+ }
310
+ if (await todos.count()) {
311
+ await todos.click();
312
+ await page.waitForTimeout(350);
313
+ }
314
+ if (await impressoras.count()) {
315
+ await impressoras.click();
316
+ await page.waitForTimeout(350);
317
+ }
318
+
319
+ expect(api.typeRequests.every(r => !r.hasArrayType)).toBe(true);
320
+ await expect(page.locator('body')).toBeVisible();
321
+ });
322
+ });
@@ -3,6 +3,7 @@
3
3
  const {
4
4
  mergeDeviceConfigs,
5
5
  buildDeviceListParams,
6
+ expandDeviceListParamSets,
6
7
  PAGE_SIZE,
7
8
  } = require('../../../../react/pages/Devices/deviceTypes/deviceListHelpers');
8
9
 
@@ -27,13 +28,57 @@ describe('deviceListHelpers', () => {
27
28
  expect(params.itemsPerPage).toBe(PAGE_SIZE);
28
29
  });
29
30
 
30
- it('buildDeviceListParams accepts multiple types', () => {
31
+ it('buildDeviceListParams omits type for empty queryTypes (All)', () => {
32
+ const params = buildDeviceListParams({
33
+ companyId: 1,
34
+ page: 1,
35
+ queryTypes: [],
36
+ });
37
+ expect(params.type).toBeUndefined();
38
+ expect(params.people).toBe('/people/1');
39
+ });
40
+
41
+ it('buildDeviceListParams does not set array type for multi (caller expands)', () => {
31
42
  const params = buildDeviceListParams({
32
43
  companyId: 1,
33
44
  page: 2,
34
- queryTypes: ['PDV', 'PRINTER'],
45
+ queryTypes: ['PRINT', 'PRINTER'],
35
46
  });
36
- expect(params.type).toEqual(['PDV', 'PRINTER']);
47
+ expect(params.type).toBeUndefined();
37
48
  expect(params.page).toBe(2);
38
49
  });
50
+
51
+ it('expandDeviceListParamSets returns one set without type for All', () => {
52
+ const sets = expandDeviceListParamSets({
53
+ companyId: 3,
54
+ page: 1,
55
+ pageSize: 50,
56
+ queryTypes: [],
57
+ });
58
+ expect(sets).toHaveLength(1);
59
+ expect(sets[0].type).toBeUndefined();
60
+ expect(sets[0].people).toBe('/people/3');
61
+ });
62
+
63
+ it('expandDeviceListParamSets returns one set per type for multi', () => {
64
+ const sets = expandDeviceListParamSets({
65
+ companyId: 5,
66
+ page: 1,
67
+ queryTypes: ['PRINT', 'PRINTER'],
68
+ });
69
+ expect(sets).toHaveLength(2);
70
+ expect(sets[0].type).toBe('PRINT');
71
+ expect(sets[1].type).toBe('PRINTER');
72
+ expect(sets[0].people).toBe('/people/5');
73
+ });
74
+
75
+ it('expandDeviceListParamSets single type stays one set', () => {
76
+ const sets = expandDeviceListParamSets({
77
+ companyId: 2,
78
+ page: 1,
79
+ queryTypes: ['DISPLAY'],
80
+ });
81
+ expect(sets).toHaveLength(1);
82
+ expect(sets[0].type).toBe('DISPLAY');
83
+ });
39
84
  });
@@ -0,0 +1,48 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const {
4
+ buildDeviceAliasStoreUpdates,
5
+ } = require('../../../react/utils/deviceAliasSync.js');
6
+
7
+ const normalizeEntityId = value => {
8
+ if (value == null || value === '') return '';
9
+ const raw = String(value);
10
+ const match = raw.match(/(\d+)\s*$/);
11
+ return match ? match[1] : raw.replace(/\D/g, '') || raw;
12
+ };
13
+
14
+ test('buildDeviceAliasStoreUpdates merges alias into device and nested config', () => {
15
+ const { mergedDevice, nextDeviceConfig } = buildDeviceAliasStoreUpdates({
16
+ deviceId: 42,
17
+ nextAlias: 'Caixa 01',
18
+ runtimeDevice: { id: 42, alias: 'Antigo', device: 'uuid-1' },
19
+ runtimeDeviceConfig: {
20
+ id: 7,
21
+ device: { id: 42, alias: 'Antigo', device: 'uuid-1' },
22
+ configs: {},
23
+ },
24
+ savedDevice: { id: 42, alias: 'Caixa 01' },
25
+ normalizeEntityId,
26
+ });
27
+
28
+ assert.equal(mergedDevice.alias, 'Caixa 01');
29
+ assert.equal(mergedDevice.id, '42');
30
+ assert.equal(mergedDevice.device, 'uuid-1');
31
+ assert.equal(nextDeviceConfig.device.alias, 'Caixa 01');
32
+ assert.equal(nextDeviceConfig.id, 7);
33
+ });
34
+
35
+ test('buildDeviceAliasStoreUpdates works without runtimeDeviceConfig', () => {
36
+ const { mergedDevice, nextDeviceConfig } = buildDeviceAliasStoreUpdates({
37
+ deviceId: '/devices/9',
38
+ nextAlias: 'Novo',
39
+ runtimeDevice: null,
40
+ runtimeDeviceConfig: null,
41
+ savedDevice: { '@id': '/devices/9', alias: 'Novo' },
42
+ normalizeEntityId,
43
+ });
44
+
45
+ assert.equal(mergedDevice.alias, 'Novo');
46
+ assert.equal(mergedDevice.id, '9');
47
+ assert.equal(nextDeviceConfig, null);
48
+ });
@@ -1,5 +1,5 @@
1
1
  const assert = require('node:assert/strict')
2
- const {test} = global
2
+ const {test} = require('node:test')
3
3
 
4
4
  const {
5
5
  getImportErrorDetail,
@@ -1,10 +1,5 @@
1
1
  const assert = require('node:assert/strict')
2
- const {beforeAll, afterAll, test} = global
3
- const {jest} = require('@jest/globals')
4
-
5
- jest.mock('@controleonline/ui-common/src/react/utils/printerDevices', () => ({
6
- getDeviceTypeLabel: value => String(value || ''),
7
- }))
2
+ const {after, before, test} = require('node:test')
8
3
 
9
4
  const {
10
5
  getRuntimeFooterDebugInfo,
@@ -46,7 +41,7 @@ const restoreLocation = () => {
46
41
  delete globalThis.location
47
42
  }
48
43
 
49
- beforeAll(() => {
44
+ before(() => {
50
45
  global.t = {
51
46
  getMessageFromBuckets: (store, type, key) =>
52
47
  store === 'common' && type === 'option'
@@ -55,7 +50,7 @@ beforeAll(() => {
55
50
  }
56
51
  })
57
52
 
58
- afterAll(() => {
53
+ after(() => {
59
54
  global.t = originalTranslator
60
55
  })
61
56