@griddo/ax 11.15.8 → 11.15.9

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "11.15.8",
4
+ "version": "11.15.9",
5
5
  "authors": [
6
6
  "Álvaro Sánchez' <alvaro.sanches@secuoyas.com>",
7
7
  "Diego M. Béjar <diego.bejar@secuoyas.com>",
@@ -219,5 +219,5 @@
219
219
  "publishConfig": {
220
220
  "access": "public"
221
221
  },
222
- "gitHead": "2d50b3828919cceb67f86fe7283056d4207a0eaf"
222
+ "gitHead": "190d5b241da3aeb5585b3c39ff11221f4e8f7d3e"
223
223
  }
@@ -1,192 +1,103 @@
1
- import React from "react";
2
- import configureStore from "redux-mock-store";
3
- import ArrayFieldGroup, { IProps } from "@ax/components/Fields/ArrayFieldGroup";
4
- import { ThemeProvider } from "styled-components";
1
+ import ArrayFieldGroup, { type IProps } from "@ax/components/Fields/ArrayFieldGroup";
2
+ import FieldsBehavior from "@ax/components/FieldsBehavior";
5
3
  import { parseTheme } from "@ax/helpers";
6
4
  import globalTheme from "@ax/themes/theme.json";
7
- import { render, cleanup, screen, fireEvent, act } from "../../../../../config/jest/test-utils";
8
- import { mock } from "jest-mock-extended";
9
- import FieldsBehavior from "@ax/components/FieldsBehavior";
10
5
 
11
- afterEach(cleanup);
6
+ import { mock } from "jest-mock-extended";
7
+ import configureStore from "redux-mock-store";
8
+ import { ThemeProvider } from "styled-components";
12
9
 
13
- const defaultProps = mock<IProps>();
10
+ import { act, cleanup, fireEvent, render, screen } from "../../../../../config/jest/test-utils";
14
11
 
15
- defaultProps.selectedContent = { component: "" };
12
+ afterEach(cleanup);
16
13
 
17
14
  const middlewares: any = [];
18
15
  const mockStore = configureStore(middlewares);
16
+ const store = mockStore({});
19
17
 
20
- const initialStore = {};
21
- const store = mockStore(initialStore);
22
-
23
- describe("ArrayFieldGroup component rendering", () => {
24
- it("should render the component with no items and button", async () => {
25
- await act(async () => {
26
- render(
27
- <ThemeProvider theme={parseTheme(globalTheme)}>
28
- <ArrayFieldGroup {...defaultProps} />
29
- </ThemeProvider>,
30
- { store },
31
- );
32
- });
33
-
34
- expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
35
- });
36
-
37
- it("should render the component with ArrayFieldInline", async () => {
38
- defaultProps.value = [
18
+ const sampleValue = [
19
+ {
20
+ id: "b71c3180-1cf2-4f69-8248-5fcb6e579377",
21
+ semTitle: "dsa",
22
+ subjects: [
39
23
  {
40
- id: "b71c3180-1cf2-4f69-8248-5fcb6e579377",
41
- semTitle: "dsa",
42
- subjects: [
43
- {
44
- id: "3ae4be00-9216-422d-a841-0448e305af08",
45
- subjTeachers: {
46
- mode: "manual",
47
- fixed: [4476, 4425],
48
- },
49
- subjTitle: "as",
50
- subjType: "das",
51
- },
52
- ],
24
+ id: "3ae4be00-9216-422d-a841-0448e305af08",
25
+ subjTeachers: { mode: "manual", fixed: [4476, 4425] },
26
+ subjTitle: "as",
27
+ subjType: "das",
53
28
  },
54
- ];
55
- const fieldProps = {
56
- objKey: "semTitle",
57
- fieldType: "TextField",
58
- innerFields: [],
59
- field: {
60
- title: "Title",
61
- type: "TextField",
62
- key: "semTitle",
63
- mandatory: "true",
64
- isTitle: true,
65
- },
66
- title: "Title",
67
- type: "TextField",
68
- mandatory: "true",
69
- isTitle: true,
70
- theme: "default-theme",
71
- };
72
- defaultProps.innerFields = [<FieldsBehavior {...fieldProps} key={1} />];
73
- defaultProps.arrayType = "inline";
74
-
75
- // rendering with innerFields in props
76
- await act(async () => {
77
- render(
78
- <ThemeProvider theme={parseTheme(globalTheme)}>
79
- <ArrayFieldGroup {...defaultProps} />
80
- </ThemeProvider>,
81
- { store },
82
- );
83
- });
84
-
85
- expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
86
- expect(screen.getByTestId("arrayFieldInline")).toBeTruthy();
87
-
88
- const fieldProps2 = {
89
- objKey: "semTitle",
90
- fieldType: "TextField",
91
- field: {
92
- title: "Title",
93
- type: "TextField",
94
- key: "semTitle",
95
- mandatory: "true",
96
- isTitle: true,
97
- },
98
- title: "Title",
99
- type: "TextField",
100
- mandatory: "true",
101
- isTitle: true,
102
- theme: "default-theme",
103
- };
104
- defaultProps.innerFields = [<FieldsBehavior {...fieldProps2} key={1} />];
105
-
106
- // rendering without innerFields in props
29
+ ],
30
+ },
31
+ ];
32
+
33
+ // Builds a fresh props object per test so mutations don't leak between cases.
34
+ const makeProps = (overrides: Partial<IProps> = {}): IProps => {
35
+ const props = mock<IProps>();
36
+ props.selectedContent = { component: "" };
37
+ props.value = sampleValue;
38
+ props.arrayType = "inline";
39
+ props.innerFields = [];
40
+ for (const [key, val] of Object.entries(overrides)) {
41
+ (props as any)[key] = val;
42
+ }
43
+ return props;
44
+ };
45
+
46
+ const makeField = (overrides: Record<string, unknown> = {}) => {
47
+ const fieldProps = {
48
+ objKey: "semTitle",
49
+ fieldType: "TextField",
50
+ value: "dsa",
51
+ innerFields: [],
52
+ field: { title: "Title", type: "TextField", key: "semTitle", mandatory: true, isTitle: true },
53
+ title: "Title",
54
+ type: "TextField",
55
+ mandatory: true,
56
+ isTitle: true,
57
+ theme: "default-theme",
58
+ ...overrides,
59
+ };
60
+ return <FieldsBehavior {...fieldProps} key={1} />;
61
+ };
62
+
63
+ const renderGroup = async (props: IProps) => {
64
+ await act(async () => {
107
65
  render(
108
66
  <ThemeProvider theme={parseTheme(globalTheme)}>
109
- <ArrayFieldGroup {...defaultProps} />
67
+ <ArrayFieldGroup {...props} />
110
68
  </ThemeProvider>,
111
69
  { store },
112
70
  );
113
71
  });
72
+ };
114
73
 
115
- it("should render the component with ArrayFieldItem", async () => {
116
- defaultProps.value = [
117
- {
118
- id: "b71c3180-1cf2-4f69-8248-5fcb6e579377",
119
- semTitle: "dsa",
120
- subjects: [
121
- {
122
- id: "3ae4be00-9216-422d-a841-0448e305af08",
123
- subjTeachers: {
124
- mode: "manual",
125
- fixed: [4476, 4425],
126
- },
127
- subjTitle: "as",
128
- subjType: "das",
129
- },
130
- ],
131
- },
132
- ];
133
- const fieldProps = {
134
- objKey: "semTitle",
135
- fieldType: "TextField",
136
- innerFields: [],
137
- field: {
138
- title: "Title",
139
- type: "TextField",
140
- key: "semTitle",
141
- mandatory: "true",
142
- isTitle: false,
143
- },
144
- title: "Title",
145
- type: "TextField",
146
- mandatory: "true",
147
- isTitle: false,
148
- theme: "default-theme",
149
- };
74
+ describe("ArrayFieldGroup component rendering", () => {
75
+ it("should render the add button when there are no items", async () => {
76
+ await renderGroup(makeProps({ value: [] }));
150
77
 
151
- defaultProps.innerFields = [<FieldsBehavior {...fieldProps} key={1} />];
152
- defaultProps.arrayType = "item";
78
+ expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
79
+ });
153
80
 
154
- await act(async () => {
155
- render(
156
- <ThemeProvider theme={parseTheme(globalTheme)}>
157
- <ArrayFieldGroup {...defaultProps} />
158
- </ThemeProvider>,
159
- { store },
160
- );
161
- });
81
+ it("should render an ArrayFieldInline per item when arrayType is inline", async () => {
82
+ await renderGroup(makeProps({ arrayType: "inline", innerFields: [makeField()] }));
162
83
 
163
84
  expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
164
- expect(screen.getByTestId("arrayFieldItem")).toBeTruthy();
85
+ expect(screen.getByTestId("arrayFieldInline")).toBeTruthy();
86
+ });
165
87
 
166
- const fieldProps2 = {
167
- objKey: "semTitle",
168
- fieldType: "TextField",
169
- field: {
170
- title: "A Title",
171
- type: "TextField",
172
- key: "semTitle",
173
- mandatory: "true",
174
- isTitle: true,
175
- },
176
- title: "A Title",
177
- type: "TextField",
178
- mandatory: "true",
179
- isTitle: true,
180
- theme: "default-theme",
181
- };
182
- defaultProps.innerFields = [<FieldsBehavior {...fieldProps2} key={1} />];
88
+ it("should render an ArrayFieldItem per item when arrayType is item", async () => {
89
+ await renderGroup(makeProps({ arrayType: "item", innerFields: [makeField({ isTitle: false })] }));
183
90
 
184
- // rendering with isTitle true
185
- render(
186
- <ThemeProvider theme={parseTheme(globalTheme)}>
187
- <ArrayFieldGroup {...defaultProps} />
188
- </ThemeProvider>,
189
- { store },
91
+ expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
92
+ expect(screen.getByTestId("arrayFieldItem")).toBeTruthy();
93
+ });
94
+
95
+ it("should use the field marked as isTitle as the item title", async () => {
96
+ await renderGroup(
97
+ makeProps({
98
+ arrayType: "item",
99
+ innerFields: [makeField({ title: "A Title", field: { key: "semTitle", isTitle: true } })],
100
+ }),
190
101
  );
191
102
 
192
103
  expect(screen.getByText("A Title")).toBeTruthy();
@@ -194,91 +105,60 @@ describe("ArrayFieldGroup component rendering", () => {
194
105
  });
195
106
 
196
107
  describe("ArrayFieldGroup component events", () => {
197
- it("should call the onclick of the button", async () => {
198
- defaultProps.value = [
199
- {
200
- id: "b71c3180-1cf2-4f69-8248-5fcb6e579377",
201
- semTitle: "dsa",
202
- subjects: [
203
- {
204
- id: "3ae4be00-9216-422d-a841-0448e305af08",
205
- subjTeachers: {
206
- mode: "manual",
207
- fixed: [4476, 4425],
208
- },
209
- subjTitle: "as",
210
- subjType: "das",
211
- },
212
- ],
213
- },
214
- ];
108
+ it("should call onChange when clicking the add button", async () => {
109
+ const props = makeProps();
110
+ const onChangeMock = props.onChange as jest.MockedFunction<IProps["onChange"]>;
215
111
 
216
- const onChangeMock = defaultProps.onChange as jest.MockedFunction<(value: Record<string, unknown>[]) => void>;
112
+ await renderGroup(props);
217
113
 
218
- await act(async () => {
219
- render(
220
- <ThemeProvider theme={parseTheme(globalTheme)}>
221
- <ArrayFieldGroup {...defaultProps} />
222
- </ThemeProvider>,
223
- { store },
224
- );
225
- });
226
-
227
- expect(screen.getByTestId("button-line-inverse")).toBeTruthy();
228
114
  fireEvent.click(screen.getByTestId("button-line-inverse"));
229
115
  expect(onChangeMock).toHaveBeenCalled();
230
116
  });
231
117
 
232
- it("should call the handleDelete with the button", async () => {
233
- defaultProps.value = [
234
- {
235
- id: "b71c3180-1cf2-4f69-8248-5fcb6e579377",
236
- semTitle: "dsa",
237
- subjects: [
238
- {
239
- id: "3ae4be00-9216-422d-a841-0448e305af08",
240
- subjTeachers: {
241
- mode: "manual",
242
- fixed: [4476, 4425],
243
- },
244
- subjTitle: "as",
245
- subjType: "das",
246
- },
247
- ],
248
- },
249
- ];
250
- const fieldProps = {
251
- objKey: "semTitle",
252
- fieldType: "TextField",
253
- innerFields: [],
254
- field: {
255
- title: "Title",
256
- type: "TextField",
257
- key: "semTitle",
258
- mandatory: "true",
259
- isTitle: true,
260
- },
261
- title: "Title",
262
- type: "TextField",
263
- mandatory: "true",
264
- isTitle: true,
265
- theme: "default-theme",
266
- };
267
- defaultProps.innerFields = [<FieldsBehavior {...fieldProps} key={1} />];
268
- defaultProps.arrayType = "inline";
269
- const onChangeMock = defaultProps.onChange as jest.MockedFunction<(value: Record<string, unknown>[]) => void>;
118
+ it("should call onChange when deleting an inline item", async () => {
119
+ const props = makeProps({ arrayType: "inline", innerFields: [makeField()] });
120
+ const onChangeMock = props.onChange as jest.MockedFunction<IProps["onChange"]>;
270
121
 
271
- await act(async () => {
272
- render(
273
- <ThemeProvider theme={parseTheme(globalTheme)}>
274
- <ArrayFieldGroup {...defaultProps} />
275
- </ThemeProvider>,
276
- { store },
277
- );
278
- });
122
+ await renderGroup(props);
279
123
 
280
124
  expect(screen.getByTestId("arrayFieldInline")).toBeTruthy();
281
125
  fireEvent.click(screen.getByTestId("icon-action-component"));
282
126
  expect(onChangeMock).toHaveBeenCalled();
283
127
  });
284
128
  });
129
+
130
+ describe("ArrayFieldGroup maxItems constraint", () => {
131
+ it("should keep the add button enabled with no tooltip when maxItems is undefined", async () => {
132
+ await renderGroup(makeProps({ value: sampleValue, maxItems: undefined }));
133
+
134
+ const button = screen.getByTestId("button-line-inverse") as HTMLButtonElement;
135
+ expect(button.disabled).toBe(false);
136
+ expect(screen.queryByTestId("tooltip-component")).toBeNull();
137
+ });
138
+
139
+ it("should keep the add button enabled while items are below maxItems", async () => {
140
+ await renderGroup(makeProps({ value: sampleValue, maxItems: 2 }));
141
+
142
+ const button = screen.getByTestId("button-line-inverse") as HTMLButtonElement;
143
+ expect(button.disabled).toBe(false);
144
+ expect(screen.queryByTestId("tooltip-component")).toBeNull();
145
+ });
146
+
147
+ it("should disable the add button and show a tooltip once maxItems is reached", async () => {
148
+ await renderGroup(makeProps({ value: sampleValue, maxItems: 1 }));
149
+
150
+ const button = screen.getByTestId("button-line-inverse") as HTMLButtonElement;
151
+ expect(button.disabled).toBe(true);
152
+ expect(screen.getByTestId("tooltip-component")).toBeTruthy();
153
+ });
154
+
155
+ it("should not call onChange when the add button is clicked at maxItems", async () => {
156
+ const props = makeProps({ value: sampleValue, maxItems: 1 });
157
+ const onChangeMock = props.onChange as jest.MockedFunction<IProps["onChange"]>;
158
+
159
+ await renderGroup(props);
160
+
161
+ fireEvent.click(screen.getByTestId("button-line-inverse"));
162
+ expect(onChangeMock).not.toHaveBeenCalled();
163
+ });
164
+ });
@@ -1,13 +1,16 @@
1
- import React from "react";
1
+ import { useMemo } from "react";
2
2
  import { connect } from "react-redux";
3
3
 
4
4
  import { IconAction } from "@ax/components";
5
- import { ISite } from "@ax/types";
6
5
  import { pageEditorActions } from "@ax/containers/PageEditor";
7
- import { getNullValue } from "@ax/helpers";
6
+ import type { ISite } from "@ax/types";
7
+
8
+ import { getArrayFields } from "../helpers";
8
9
 
9
10
  import * as S from "./style";
10
11
 
12
+ const getInlineKey = (field: any): string => field.props.objKey;
13
+
11
14
  const ArrayFieldInline = (props: IProps): JSX.Element => {
12
15
  const {
13
16
  fields,
@@ -25,46 +28,22 @@ const ArrayFieldInline = (props: IProps): JSX.Element => {
25
28
 
26
29
  const deleteItem = () => handleDelete(index);
27
30
 
28
- const getFields = (fields: any[]): any[] => {
29
- return fields.map((field: any) => {
30
- const key = field.props.objKey;
31
-
32
- const handleChange = (newValue: any) => onChange({ [key]: newValue });
33
-
34
- const innerFields = field.props.innerFields ? getFields(field.props.innerFields) : undefined;
35
- const hasMultipleOptions = field.props.whiteList && field.props.whiteList.length > 1;
36
-
37
- const actionsField = {
38
- ...actions,
39
- addComponentAction: (component: any, key: string) =>
40
- addComponentInArray(component, key, parentKey, index, hasMultipleOptions),
41
- replaceModuleAction: (module: string, parent: any, key: string) =>
42
- replaceModuleInArray(module, parent, key, parentKey, index),
43
- };
44
-
45
- const hideField = () => {
46
- const isContainer = field.props.type === "ComponentContainer";
47
-
48
- const nullValue = isContainer && item[key] ? getNullValue(item[key], hasMultipleOptions) : null;
49
- onChange({ [key]: nullValue });
50
- };
51
-
52
- return {
53
- ...field,
54
- props: {
55
- ...field.props,
56
- value: item[key],
57
- objKey: key,
58
- onChange: handleChange,
59
- innerFields,
60
- site,
61
- actions: actionsField,
62
- hideField,
63
- },
64
- key,
65
- };
66
- });
67
- };
31
+ const renderedFields = useMemo(
32
+ () =>
33
+ getArrayFields({
34
+ fields,
35
+ item,
36
+ index,
37
+ parentKey,
38
+ site,
39
+ actions,
40
+ onChange,
41
+ addComponentInArray,
42
+ replaceModuleInArray,
43
+ getKey: getInlineKey,
44
+ }),
45
+ [fields, item, index, parentKey, site, actions, onChange, addComponentInArray, replaceModuleInArray],
46
+ );
68
47
 
69
48
  return (
70
49
  <S.Wrapper data-testid="arrayFieldInline">
@@ -72,7 +51,7 @@ const ArrayFieldInline = (props: IProps): JSX.Element => {
72
51
  <S.IconWrapper>
73
52
  <IconAction icon="delete" onClick={deleteItem} size="s" disabled={disabled} />
74
53
  </S.IconWrapper>
75
- {getFields(fields)}
54
+ {renderedFields}
76
55
  </S.Content>
77
56
  </S.Wrapper>
78
57
  );
@@ -82,7 +61,7 @@ interface IProps {
82
61
  fields: any[];
83
62
  item: Record<string, unknown>;
84
63
  index: number;
85
- onChange: (value: Record<string, unknown>) => void;
64
+ onChange: (value: Record<string, unknown>, index: number) => void;
86
65
  handleDelete: (index: number) => void;
87
66
  site: ISite | null;
88
67
  disabled?: boolean;
@@ -1,11 +1,15 @@
1
- import React from "react";
1
+ import { useMemo } from "react";
2
2
  import { connect } from "react-redux";
3
- import { ISite } from "@ax/types";
3
+
4
4
  import { pageEditorActions } from "@ax/containers/PageEditor";
5
- import { getNullValue } from "@ax/helpers";
5
+ import type { ISite } from "@ax/types";
6
+
7
+ import { getArrayFields } from "../helpers";
6
8
 
7
9
  import * as S from "./style";
8
10
 
11
+ const getItemKey = (field: any): string => field.key;
12
+
9
13
  const ArrayFieldItem = (props: IProps): JSX.Element => {
10
14
  const {
11
15
  fields,
@@ -46,46 +50,22 @@ const ArrayFieldItem = (props: IProps): JSX.Element => {
46
50
  return `${name} ${index + 1}`;
47
51
  };
48
52
 
49
- const getFields = (fields: any[]): any[] => {
50
- return fields.map((field: any) => {
51
- const { key } = field;
52
-
53
- const handleChange = (newValue: any) => onChange({ [key]: newValue });
54
-
55
- const innerFields = field.props.innerFields ? getFields(field.props.innerFields) : undefined;
56
- const hasMultipleOptions = field.props.whiteList && field.props.whiteList.length > 1;
57
-
58
- const actionsField = {
59
- ...actions,
60
- addComponentAction: (component: any, key: string) =>
61
- addComponentInArray(component, key, parentKey, index, hasMultipleOptions),
62
- replaceModuleAction: (module: string, parent: any, key: string) =>
63
- replaceModuleInArray(module, parent, key, parentKey, index),
64
- };
65
-
66
- const hideField = () => {
67
- const isContainer = field.props.type === "ComponentContainer";
68
-
69
- const nullValue = isContainer && item[key] ? getNullValue(item[key], hasMultipleOptions) : null;
70
- onChange({ [key]: nullValue });
71
- };
72
-
73
- return {
74
- ...field,
75
- props: {
76
- ...field.props,
77
- value: item[key],
78
- objKey: key,
79
- onChange: handleChange,
80
- innerFields,
81
- site,
82
- actions: actionsField,
83
- hideField,
84
- },
85
- key,
86
- };
87
- });
88
- };
53
+ const renderedFields = useMemo(
54
+ () =>
55
+ getArrayFields({
56
+ fields,
57
+ item,
58
+ index,
59
+ parentKey,
60
+ site,
61
+ actions,
62
+ onChange,
63
+ addComponentInArray,
64
+ replaceModuleInArray,
65
+ getKey: getItemKey,
66
+ }),
67
+ [fields, item, index, parentKey, site, actions, onChange, addComponentInArray, replaceModuleInArray],
68
+ );
89
69
 
90
70
  return (
91
71
  <S.Wrapper data-testid="arrayFieldItem" isOpen={isOpen === index}>
@@ -93,7 +73,7 @@ const ArrayFieldItem = (props: IProps): JSX.Element => {
93
73
  {getItemTitle(fields)}
94
74
  <S.StyledActionMenu icon="more" options={menuOptions} />
95
75
  </S.Title>
96
- <S.Content isOpen={isOpen === index}>{getFields(fields)}</S.Content>
76
+ <S.Content isOpen={isOpen === index}>{renderedFields}</S.Content>
97
77
  </S.Wrapper>
98
78
  );
99
79
  };
@@ -103,7 +83,7 @@ interface IProps {
103
83
  fields: any[];
104
84
  item: Record<string, unknown>;
105
85
  index: number;
106
- onChange: (value: Record<string, unknown>) => void;
86
+ onChange: (value: Record<string, unknown>, index: number) => void;
107
87
  handleDelete: (index: number) => void;
108
88
  isOpen: number | null;
109
89
  setIsOpen: (index: number | null) => void;
@@ -0,0 +1,72 @@
1
+ import { getNullValue } from "@ax/helpers";
2
+ import type { ISite } from "@ax/types";
3
+
4
+ interface IGetArrayFieldsParams {
5
+ fields: any[];
6
+ item: Record<string, unknown>;
7
+ index: number;
8
+ parentKey: string;
9
+ site: ISite | null;
10
+ actions: any;
11
+ onChange: (value: Record<string, unknown>, index: number) => void;
12
+ addComponentInArray: (
13
+ type: string,
14
+ key: string,
15
+ parentKey: string,
16
+ index: number,
17
+ isMultiple: boolean,
18
+ ) => void;
19
+ replaceModuleInArray: (module: string, parent: any, key: string, parentKey: string, index: number) => void;
20
+ /** Each array variant resolves the field key differently (props.objKey vs field.key). */
21
+ getKey: (field: any) => string;
22
+ }
23
+
24
+ /**
25
+ * Builds the inner field descriptors for an array item. Shared by ArrayFieldInline
26
+ * and ArrayFieldItem so the transformation lives in a single place.
27
+ */
28
+ export const getArrayFields = (params: IGetArrayFieldsParams): any[] => {
29
+ const { fields, item, index, parentKey, site, actions, onChange, addComponentInArray, replaceModuleInArray, getKey } =
30
+ params;
31
+
32
+ return fields.map((field: any) => {
33
+ const key = getKey(field);
34
+
35
+ const handleChange = (newValue: any) => onChange({ [key]: newValue }, index);
36
+
37
+ const innerFields = field.props.innerFields
38
+ ? getArrayFields({ ...params, fields: field.props.innerFields })
39
+ : undefined;
40
+ const hasMultipleOptions = field.props.whiteList && field.props.whiteList.length > 1;
41
+
42
+ const actionsField = {
43
+ ...actions,
44
+ addComponentAction: (component: any, key: string) =>
45
+ addComponentInArray(component, key, parentKey, index, hasMultipleOptions),
46
+ replaceModuleAction: (module: string, parent: any, key: string) =>
47
+ replaceModuleInArray(module, parent, key, parentKey, index),
48
+ };
49
+
50
+ const hideField = () => {
51
+ const isContainer = field.props.type === "ComponentContainer";
52
+
53
+ const nullValue = isContainer && item[key] ? getNullValue(item[key], hasMultipleOptions) : null;
54
+ onChange({ [key]: nullValue }, index);
55
+ };
56
+
57
+ return {
58
+ ...field,
59
+ props: {
60
+ ...field.props,
61
+ value: item[key],
62
+ objKey: key,
63
+ onChange: handleChange,
64
+ innerFields,
65
+ site,
66
+ actions: actionsField,
67
+ hideField,
68
+ },
69
+ key,
70
+ };
71
+ });
72
+ };
@@ -1,6 +1,6 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
- import { Button, FieldsDivider } from "@ax/components";
3
+ import { Button, FieldsDivider, Tooltip } from "@ax/components";
4
4
  import { getDefaultSchema } from "@ax/helpers";
5
5
  import type { ISite } from "@ax/types";
6
6
 
@@ -25,11 +25,19 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
25
25
  objKey,
26
26
  selectedContent,
27
27
  actions,
28
+ maxItems,
28
29
  } = props;
29
30
 
30
31
  const [items, setItems] = useState<any[]>([]);
31
32
  const [isOpen, setIsOpen] = useState<number | null>(null);
32
33
 
34
+ // Keeps the latest items accessible from stable callbacks without
35
+ // re-creating them on every change (which would break child memoization).
36
+ const itemsRef = useRef(items);
37
+ itemsRef.current = items;
38
+
39
+ const hasMaxItems = !!maxItems && items.length >= maxItems;
40
+
33
41
  // biome-ignore lint/correctness/useExhaustiveDependencies: false positive
34
42
  useEffect(() => {
35
43
  const initialValue = value ? Object.values(value) : [];
@@ -40,6 +48,8 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
40
48
  }, [editorID]);
41
49
 
42
50
  const handleClick = () => {
51
+ if (hasMaxItems) return;
52
+
43
53
  let newComponent = { id: uuidv4() };
44
54
  if (selectedContent) {
45
55
  const defaultValues = getDefaultSchema(selectedContent.component);
@@ -49,28 +59,34 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
49
59
  : newComponent;
50
60
  }
51
61
 
52
- const newItems = [...items, newComponent];
62
+ const newItems = [...itemsRef.current, newComponent];
53
63
  setItems(newItems);
54
64
  onChange(newItems);
55
65
  setIsOpen(newItems.length - 1);
56
66
  };
57
67
 
58
- const handleChange = (newValue: Record<string, unknown>, index: number) => {
59
- const updatedItems = [...items];
60
- updatedItems[index] = {
61
- ...updatedItems[index],
62
- ...newValue,
63
- };
64
- setItems(updatedItems);
65
- onChange(updatedItems);
66
- };
68
+ const handleChange = useCallback(
69
+ (newValue: Record<string, unknown>, index: number) => {
70
+ const updatedItems = [...itemsRef.current];
71
+ updatedItems[index] = {
72
+ ...updatedItems[index],
73
+ ...newValue,
74
+ };
75
+ setItems(updatedItems);
76
+ onChange(updatedItems);
77
+ },
78
+ [onChange],
79
+ );
67
80
 
68
- const handleDelete = (index: number) => {
69
- const updatedItems = [...items];
70
- updatedItems.splice(index, 1);
71
- setItems(updatedItems);
72
- onChange(updatedItems);
73
- };
81
+ const handleDelete = useCallback(
82
+ (index: number) => {
83
+ const updatedItems = [...itemsRef.current];
84
+ updatedItems.splice(index, 1);
85
+ setItems(updatedItems);
86
+ onChange(updatedItems);
87
+ },
88
+ [onChange],
89
+ );
74
90
 
75
91
  const DividerComponent = () => (divider ? <FieldsDivider data={divider} /> : null);
76
92
 
@@ -79,14 +95,13 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
79
95
  <DividerComponent />
80
96
  <div>
81
97
  {items?.map((item, index) => {
82
- const handleFieldChange = (newValue: Record<string, unknown>) => handleChange(newValue, index);
83
98
  return arrayType === "inline" ? (
84
99
  <ArrayFieldInline
85
100
  key={item.id}
86
101
  fields={innerFields}
87
102
  item={item}
88
103
  index={index}
89
- onChange={handleFieldChange}
104
+ onChange={handleChange}
90
105
  handleDelete={handleDelete}
91
106
  site={site}
92
107
  disabled={disabled}
@@ -100,7 +115,7 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
100
115
  fields={innerFields}
101
116
  item={item}
102
117
  index={index}
103
- onChange={handleFieldChange}
118
+ onChange={handleChange}
104
119
  handleDelete={handleDelete}
105
120
  isOpen={isOpen}
106
121
  setIsOpen={setIsOpen}
@@ -113,9 +128,11 @@ const ArrayFieldGroup = (props: IProps): JSX.Element => {
113
128
  })}
114
129
  </div>
115
130
  <S.ButtonWrapper>
116
- <Button type="button" onClick={handleClick} buttonStyle="line" disabled={disabled}>
117
- {`Add ${name}`}
118
- </Button>
131
+ <Tooltip content={hasMaxItems ? "Item limit reached" : undefined}>
132
+ <Button type="button" onClick={handleClick} buttonStyle="line" disabled={disabled || hasMaxItems}>
133
+ {`Add ${name}`}
134
+ </Button>
135
+ </Tooltip>
119
136
  </S.ButtonWrapper>
120
137
  </div>
121
138
  );
@@ -134,6 +151,7 @@ export interface IProps {
134
151
  objKey: string;
135
152
  selectedContent: any;
136
153
  actions: any;
154
+ maxItems?: number;
137
155
  }
138
156
 
139
157
  export default ArrayFieldGroup;