@arquimedes.co/eureka-forms 3.0.70 → 3.0.71

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.
@@ -7,6 +7,7 @@ import axios from 'axios';
7
7
  import CalendarIcon from '../Icons/CalendarIcon';
8
8
  import LocationIcon from '../Icons/LocationIcon';
9
9
  import FileIcon from '../Icons/FileIcon';
10
+ import { ciudadesICAQuery } from './ciudadesICAQuery';
10
11
  const baseUrl = `https://api.integrations.${import.meta.env.VITE_REACT_APP_DOMAIN}/sinco/ayf/`;
11
12
  function AYFStepMapper(props) {
12
13
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -29,30 +30,7 @@ function AYFStepMapper(props) {
29
30
  const getCiudadesOptions = async (step, dependencyStore, { idOrganization }) => {
30
31
  if (!idOrganization)
31
32
  return null;
32
- const nit = dependencyStore[step.idNitStep]?.value;
33
- const params = new URLSearchParams({
34
- idOrganization,
35
- idCompany: nit,
36
- nit: 'true',
37
- });
38
- for (const dep of step.dependencies ?? []) {
39
- const value = dependencyStore[dep]?.value;
40
- if (value && dep.startsWith('AYF')) {
41
- switch (dep.split('-')?.[0]) {
42
- case AYFFormStepTypes.AYF_START_MONTH:
43
- params.set('startMonth', value.id);
44
- break;
45
- case AYFFormStepTypes.AYF_END_MONTH:
46
- params.set('endMonth', value.id);
47
- break;
48
- case AYFFormStepTypes.AYF_YEAR:
49
- params.set('year', value);
50
- break;
51
- default:
52
- break;
53
- }
54
- }
55
- }
33
+ const params = ciudadesICAQuery(step, dependencyStore, idOrganization);
56
34
  try {
57
35
  return (await axios.get(baseUrl + 'CiudadesICA?' + params.toString())).data;
58
36
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFormContext } from 'react-hook-form';
3
3
  import ErkSmartSelect from '../../../Shared/ErkSmartSelect/ErkSmartSelect';
4
- import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
4
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
5
5
  import React from 'react';
6
6
  import FormContext from '../../../Contexts/FormContext';
7
7
  import { useAppDispatch, useAppSelector } from '../../../hooks';
@@ -28,6 +28,11 @@ function SmartSelectStep({ step, editable, getOptions, calcDepError, defaultValu
28
28
  const [loading, setLoading] = useState(false);
29
29
  const [firstTime, setFirstTime] = useState(true);
30
30
  const [touched, setTouched] = useState(false);
31
+ /**
32
+ * Every dependency change (each keystroke of a NIT, say) starts a new request without cancelling the
33
+ * ones in flight, and they can resolve in any order. Only the latest request may touch the options.
34
+ */
35
+ const lastRequest = useRef(0);
31
36
  const dispatch = useAppDispatch();
32
37
  const allDeps = useMemo(() => [...(step.dependencies ?? []), ...getOptionalDependencies(step)], [getOptionalDependencies, step]);
33
38
  const idDependencies = useMemo(() => getOptionsConditionsIdSteps(step), [getOptionsConditionsIdSteps, step]);
@@ -52,6 +57,7 @@ function SmartSelectStep({ step, editable, getOptions, calcDepError, defaultValu
52
57
  // eslint-disable-next-line react-hooks/exhaustive-deps
53
58
  }, [allDepsValues]);
54
59
  const calcOptions = async (firstTime) => {
60
+ const request = ++lastRequest.current;
55
61
  if (!firstTime)
56
62
  setOptions(undefined);
57
63
  if (value && !firstTime)
@@ -61,6 +67,8 @@ function SmartSelectStep({ step, editable, getOptions, calcDepError, defaultValu
61
67
  idOrganization,
62
68
  idCurrentAgent: postview ? undefined : idCurrentAgent,
63
69
  });
70
+ if (request !== lastRequest.current)
71
+ return;
64
72
  if (value) {
65
73
  /** If the value is still valid select it */
66
74
  if (resp && getValueString(value) !== undefined) {
@@ -82,6 +90,8 @@ function SmartSelectStep({ step, editable, getOptions, calcDepError, defaultValu
82
90
  }
83
91
  }
84
92
  catch (error) {
93
+ if (request !== lastRequest.current)
94
+ return;
85
95
  console.error(error);
86
96
  setOptions(null);
87
97
  }
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { act, render } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import '@testing-library/jest-dom';
5
+ import { Provider } from 'react-redux';
6
+ import { FormProvider, useForm } from 'react-hook-form';
7
+ import { configureStore } from '@reduxjs/toolkit';
8
+ import { EurekaFormsReducer, defaultRootState } from '../../Utils/store';
9
+ import { RootApi } from '../../Utils/_api';
10
+ import FormStepTypes from '../../constants/FormStepTypes';
11
+ import { StoreContext } from '../../Utils/StoreContext';
12
+ import FormContext, { IdFormContext } from '../../Contexts/FormContext';
13
+ import SectionContext from '../../Contexts/SectionContext';
14
+ import MaterialProviders from '../../Utils/MaterialProviders';
15
+ import InternalFormStyle from '../../constants/InternalFormStyle';
16
+ import { setStepDependency } from '../../States/SiteSlice';
17
+ import SmartSelectStep from './SmartSelectStep';
18
+ const ID_FORM = 'STALE_OPTIONS_FORM';
19
+ const nitStep = {
20
+ id: 'nit-step',
21
+ idSection: 'SECTION_1',
22
+ stepPath: ['nit-step'],
23
+ type: FormStepTypes.TEXTINPUT,
24
+ label: 'NIT',
25
+ description: '',
26
+ required: true,
27
+ size: 1,
28
+ maxLength: 20,
29
+ };
30
+ /** A city-like selector whose options come from the NIT the user is still typing. */
31
+ const cityStep = {
32
+ id: 'city-step',
33
+ idSection: 'SECTION_1',
34
+ stepPath: ['city-step'],
35
+ type: 'AYF_ICA_CITY',
36
+ label: 'Ciudad',
37
+ description: '',
38
+ showIcon: false,
39
+ required: false,
40
+ searchable: false,
41
+ size: 1,
42
+ dependencies: ['nit-step'],
43
+ };
44
+ const form = {
45
+ firstSection: 'SECTION_1',
46
+ sections: {
47
+ SECTION_1: { id: 'SECTION_1', name: 'Sección 1', steps: ['nit-step', 'city-step'], nextSection: null },
48
+ },
49
+ steps: { 'nit-step': nitStep, 'city-step': cityStep },
50
+ confirmationMessage: { blocks: [], entityMap: {} },
51
+ showLink: false,
52
+ size: { blockSize: 200, blockNum: 4, spacingSize: 10 },
53
+ };
54
+ const dependencies = {
55
+ 'nit-step': { idOriginal: 'nit-step', dependents: [cityStep], value: '', type: FormStepTypes.TEXTINPUT },
56
+ };
57
+ function makeStore() {
58
+ return configureStore({
59
+ reducer: { forms: EurekaFormsReducer, [RootApi.reducerPath]: RootApi.reducer },
60
+ preloadedState: {
61
+ forms: { [ID_FORM]: { ...defaultRootState, site: { ...defaultRootState.site, dependencies } } },
62
+ },
63
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(RootApi.middleware),
64
+ });
65
+ }
66
+ function Harness({ store, requests }) {
67
+ const methods = useForm({ mode: 'onTouched' });
68
+ return (_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(Provider, { store: store, context: StoreContext, children: _jsx(IdFormContext.Provider, { value: ID_FORM, children: _jsx(FormContext.Provider, { value: form, children: _jsx(SectionContext.Provider, { value: "SECTION_1", children: _jsx(FormProvider, { ...methods, children: _jsx(SmartSelectStep, { step: cityStep, editable: true, getOptions: (_step, deps) => new Promise((resolve) => {
69
+ requests.push({ nit: deps['nit-step']?.value, resolve });
70
+ }), getOptionSelected: (option, value) => option === value, getValueString: (value) => value }) }) }) }) }) }) }));
71
+ }
72
+ const optionTexts = () => Array.from(document.querySelectorAll('[role=option]')).map((option) => option.textContent ?? '');
73
+ describe('SmartSelectStep when a dependency changes faster than its options load', () => {
74
+ it('keeps the options of the latest dependency value when an older request resolves last', async () => {
75
+ const store = makeStore();
76
+ const requests = [];
77
+ render(_jsx(Harness, { store: store, requests: requests }));
78
+ const typeNit = (value) => {
79
+ act(() => {
80
+ store.dispatch({ ...setStepDependency({ step: nitStep, value }), meta: { idForm: ID_FORM } });
81
+ });
82
+ };
83
+ typeNit('9');
84
+ typeNit('901379420');
85
+ const byNit = (nit) => requests.find((request) => request.nit === nit);
86
+ expect(byNit('9')).toBeDefined();
87
+ expect(byNit('901379420')).toBeDefined();
88
+ // The full NIT answers first with cities; the partial NIT straggles in afterwards with nothing.
89
+ const settle = (pending, options) => act(async () => {
90
+ pending.resolve(options);
91
+ await Promise.resolve();
92
+ });
93
+ await settle(byNit('901379420'), ['BELLO', 'MEDELLIN']);
94
+ await settle(byNit('9'), []);
95
+ act(() => {
96
+ document.querySelector('[role=combobox]').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
97
+ });
98
+ expect(optionTexts()).toEqual(expect.arrayContaining(['BELLO', 'MEDELLIN']));
99
+ });
100
+ });
@@ -0,0 +1,8 @@
1
+ import { GSmartSelect } from '../@Types/GenericFormSteps';
2
+ import { DependencyStore } from '../Form/Form';
3
+ /**
4
+ * Query for `CiudadesICA`. The NIT dependency is `null` until the user types it; it must be left out of the
5
+ * query rather than serialised as the text "null", which the integrations API would take as a real NIT (and
6
+ * SINCO answers that with no cities instead of the organisation's main city).
7
+ */
8
+ export declare function ciudadesICAQuery(step: GSmartSelect, dependencyStore: DependencyStore, idOrganization: string): URLSearchParams;
@@ -0,0 +1,31 @@
1
+ import AYFFormStepTypes from '../constants/AYFFormStepTypes';
2
+ /**
3
+ * Query for `CiudadesICA`. The NIT dependency is `null` until the user types it; it must be left out of the
4
+ * query rather than serialised as the text "null", which the integrations API would take as a real NIT (and
5
+ * SINCO answers that with no cities instead of the organisation's main city).
6
+ */
7
+ export function ciudadesICAQuery(step, dependencyStore, idOrganization) {
8
+ const params = new URLSearchParams({ idOrganization, nit: 'true' });
9
+ const nit = dependencyStore[step.idNitStep]?.value;
10
+ if (nit)
11
+ params.set('idCompany', nit);
12
+ for (const dep of step.dependencies ?? []) {
13
+ const value = dependencyStore[dep]?.value;
14
+ if (value && dep.startsWith('AYF')) {
15
+ switch (dep.split('-')?.[0]) {
16
+ case AYFFormStepTypes.AYF_START_MONTH:
17
+ params.set('startMonth', value.id);
18
+ break;
19
+ case AYFFormStepTypes.AYF_END_MONTH:
20
+ params.set('endMonth', value.id);
21
+ break;
22
+ case AYFFormStepTypes.AYF_YEAR:
23
+ params.set('year', value);
24
+ break;
25
+ default:
26
+ break;
27
+ }
28
+ }
29
+ }
30
+ return params;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import AYFFormStepTypes from '../constants/AYFFormStepTypes';
3
+ import FormStepTypes from '../constants/FormStepTypes';
4
+ import { ciudadesICAQuery } from './ciudadesICAQuery';
5
+ const cityStep = {
6
+ id: 'AYF_ICA_CITY-1',
7
+ idSection: 'SECTION_1',
8
+ stepPath: ['AYF_ICA_CITY-1'],
9
+ type: AYFFormStepTypes.AYF_ICA_CITY,
10
+ label: 'Ciudad',
11
+ description: '',
12
+ showIcon: false,
13
+ required: false,
14
+ size: 1,
15
+ idNitStep: 'TEXTINPUT-nit',
16
+ dependencies: ['TEXTINPUT-nit', 'AYF_YEAR-1'],
17
+ };
18
+ const storeWithNit = (value) => ({
19
+ 'TEXTINPUT-nit': { idOriginal: 'TEXTINPUT-nit', dependents: [], value, type: FormStepTypes.TEXTINPUT },
20
+ 'AYF_YEAR-1': { idOriginal: 'AYF_YEAR-1', dependents: [], value: '2026', type: AYFFormStepTypes.AYF_YEAR },
21
+ });
22
+ describe('ciudadesICAQuery', () => {
23
+ it('sends the NIT as idCompany once the user has typed it', () => {
24
+ const params = ciudadesICAQuery(cityStep, storeWithNit('901379420'), 'casa');
25
+ expect(params.get('idCompany')).toBe('901379420');
26
+ expect(params.get('nit')).toBe('true');
27
+ expect(params.get('year')).toBe('2026');
28
+ });
29
+ /**
30
+ * The NIT dependency is `null` until the user types something. Serialising that as the text "null"
31
+ * makes the integrations API query SINCO for a provider literally called "null", which answers with no
32
+ * cities instead of the organisation's main city.
33
+ */
34
+ it.each([null, undefined, ''])('omits idCompany while the NIT is %s', (nit) => {
35
+ const params = ciudadesICAQuery(cityStep, storeWithNit(nit), 'casa');
36
+ expect(params.has('idCompany')).toBe(false);
37
+ expect(params.get('idOrganization')).toBe('casa');
38
+ });
39
+ });
@@ -5,6 +5,8 @@ import { StyledTextField } from '../ErkTextField/ErkTextField';
5
5
  import ErkMenuItem from '../ErkMenuItem';
6
6
  import ErkAutocompleteOption from '../ErkAutocompleteOption';
7
7
  const filter = createFilterOptions();
8
+ /** Options are either `{ label }` objects or plain strings that are their own label (e.g. the ICA cities). */
9
+ const calcOptionLabel = (option) => (typeof option === 'string' ? option : (option?.label ?? ''));
8
10
  const StyledAutoComplete = styled(MuiAutocomplete)(({ theme, readOnly, ...props }) => ({
9
11
  '& .Erk-MuiInputBase-root': {
10
12
  cursor: props.disabled || readOnly ? 'default' : 'text',
@@ -35,21 +37,9 @@ export default function ErkSmartSelect({ options, value = '', onChange, label, g
35
37
  else {
36
38
  return filter(options, params);
37
39
  }
38
- }, renderOption: ({ key, ...props }, option, state) => (_jsx(ErkAutocompleteOption, { ...props, selected: state.selected, children: option.label }, key)), getOptionKey: (option) => getValueString(option), getOptionLabel: (option) => {
39
- // Value selected with enter, right from the input
40
- if (typeof option === 'string') {
41
- return option;
42
- }
43
- else if (option?.inputValue) {
44
- return option.inputValue ?? '';
45
- }
46
- else {
47
- // Regular option
48
- return option?.label ?? '';
49
- }
50
- }, slotProps: { popper: { sx: { zIndex: 1305 } } }, renderInput: (params) => (_jsx(StyledTextField, { ...params, size: size, label: label, labelMargin: 0, name: others.name, readOnly: readOnly, disabled: disabled, inputRef: inputRef, error: others.error, onBlur: others.onBlur, required: others.required, helperText: others.helperText })) }));
40
+ }, renderOption: ({ key, ...props }, option, state) => (_jsx(ErkAutocompleteOption, { ...props, selected: state.selected, children: calcOptionLabel(option) }, key)), getOptionKey: (option) => getValueString(option), getOptionLabel: (option) => option?.inputValue ?? calcOptionLabel(option), slotProps: { popper: { sx: { zIndex: 1305 } } }, renderInput: (params) => (_jsx(StyledTextField, { ...params, size: size, label: label, labelMargin: 0, name: others.name, readOnly: readOnly, disabled: disabled, inputRef: inputRef, error: others.error, onBlur: others.onBlur, required: others.required, helperText: others.helperText })) }));
51
41
  }
52
42
  else {
53
- return (_jsxs(ErkSelect, { ...others, labelMargin: 0, disabled: disabled, readOnly: readOnly, inputRef: inputRef, onChange: (value) => onChange?.(options?.find((option) => getValueString(option) === value)), value: loading ? '' : getValueString(value), label: label, IconComponent: loading ? () => _jsx(CircularProgress, { size: 22 }) : IconComponent, children: [options?.map((option) => (_jsx(ErkMenuItem, { value: getValueString(option), "data-testid": 'smart-select-' + getValueString(option), children: option.label }, getValueString(option)))), options === undefined && value && !loading && (_jsx(ErkMenuItem, { value: getValueString(value), children: value.label ?? '' }, getValueString(value)))] }));
43
+ return (_jsxs(ErkSelect, { ...others, labelMargin: 0, disabled: disabled, readOnly: readOnly, inputRef: inputRef, onChange: (value) => onChange?.(options?.find((option) => getValueString(option) === value)), value: loading ? '' : getValueString(value), label: label, IconComponent: loading ? () => _jsx(CircularProgress, { size: 22 }) : IconComponent, children: [options?.map((option) => (_jsx(ErkMenuItem, { value: getValueString(option), "data-testid": 'smart-select-' + getValueString(option), children: calcOptionLabel(option) }, getValueString(option)))), options === undefined && value && !loading && (_jsx(ErkMenuItem, { value: getValueString(value), children: calcOptionLabel(value) }, getValueString(value)))] }));
54
44
  }
55
45
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { render, screen } from '@testing-library/react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
5
  import '@testing-library/jest-dom';
@@ -31,3 +31,23 @@ describe('ErkSmartSelect (searchable)', () => {
31
31
  expect(await screen.findByRole('option', { name: 'Uno' })).toHaveAttribute('aria-selected', 'true');
32
32
  });
33
33
  });
34
+ /** Steps such as the ICA city selector feed plain strings; the string is both the value and the label. */
35
+ const cities = ['BELLO', 'MEDELLIN'];
36
+ const cityString = (value) => value;
37
+ describe('ErkSmartSelect with plain-string options', () => {
38
+ it('searchable: shows each string as the option text', async () => {
39
+ const user = userEvent.setup();
40
+ render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkSmartSelect, { searchable: true, label: "Ciudad", options: cities, value: "", onChange: () => { }, getValueString: cityString }) }));
41
+ await user.click(screen.getByRole('combobox'));
42
+ expect(await screen.findByRole('option', { name: 'BELLO' })).toBeInTheDocument();
43
+ expect(screen.getByRole('option', { name: 'MEDELLIN' })).toBeInTheDocument();
44
+ });
45
+ it('non-searchable: shows each string as the option text', () => {
46
+ render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkSmartSelect, { searchable: false, label: "Ciudad", options: cities, value: "", onChange: () => { }, getValueString: cityString }) }));
47
+ // The inline (disablePortal) menu marks the rest of the tree aria-hidden while open, so the
48
+ // options are read from the raw DOM instead of through role queries.
49
+ fireEvent.mouseDown(document.querySelector('[role=combobox]'));
50
+ expect(document.querySelector('[role=option][data-value=BELLO]')).toHaveTextContent('BELLO');
51
+ expect(document.querySelector('[role=option][data-value=MEDELLIN]')).toHaveTextContent('MEDELLIN');
52
+ });
53
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arquimedes.co/eureka-forms",
3
3
  "repository": "git://github.com/Arquimede5/Eureka-Forms.git",
4
- "version": "3.0.70",
4
+ "version": "3.0.71",
5
5
  "scripts": {
6
6
  "watch": "node node_modules/@typescript/native/bin/tsc --noEmit --watch --project tsconfig.app.json",
7
7
  "start": "vite",