From d24439ee72fe4f105e71dd98d570094cf0143618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Jul 2026 15:11:32 -0300 Subject: [PATCH 01/14] fix: add new v2 versions of draggable components replacing react-beautiful-dnd for @dnd-kit/sortable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 3 + .../mui/__tests__/additional-input-v2.test.js | 165 +++++++++ .../__tests__/meta-field-values-v2.test.js | 332 ++++++++++++++++++ .../__tests__/mui-table-sortable-v2.test.js | 182 ++++++++++ .../additional-input-list-v2.js | 123 +++++++ .../additional-input/additional-input-v2.js | 207 +++++++++++ .../additional-input/meta-field-values-v2.js | 201 +++++++++++ .../sortable-table/mui-table-sortable-v2.js | 328 +++++++++++++++++ .../mui/sortable-table/sortable-row.js | 62 ++++ webpack.common.js | 3 + 10 files changed, 1606 insertions(+) create mode 100644 src/components/mui/__tests__/additional-input-v2.test.js create mode 100644 src/components/mui/__tests__/meta-field-values-v2.test.js create mode 100644 src/components/mui/__tests__/mui-table-sortable-v2.test.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-v2.js create mode 100644 src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js create mode 100644 src/components/mui/sortable-table/mui-table-sortable-v2.js create mode 100644 src/components/mui/sortable-table/sortable-row.js diff --git a/src/components/index.js b/src/components/index.js index ea54b8e6..17b24c0f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -141,3 +141,6 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) // export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +// export {default as MuiSortableTableV2} from './mui/sortable-table/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +// export {default as MuiAdditionalInputV2} from './mui/formik-inputs/additional-input/additional-input-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) +// export {default as MuiAdditionalInputListV2} from './mui/formik-inputs/additional-input/additional-input-list-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) diff --git a/src/components/mui/__tests__/additional-input-v2.test.js b/src/components/mui/__tests__/additional-input-v2.test.js new file mode 100644 index 00000000..9d9aa402 --- /dev/null +++ b/src/components/mui/__tests__/additional-input-v2.test.js @@ -0,0 +1,165 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +// Integration coverage between AdditionalInputV2 and the real MetaFieldValuesV2 +// (not mocked here, unlike additional-input.test.js) to verify baseName/fieldIndex +// wiring actually reaches the meta-field-values actions through the parent. + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik, Form, useFormikContext } from "formik"; +import "@testing-library/jest-dom"; +import AdditionalInputV2 from "../formik-inputs/additional-input/additional-input-v2"; +import showConfirmDialog from "../showConfirmDialog"; + +jest.mock("../showConfirmDialog", () => jest.fn()); + +jest.mock( + "../DragNDropList", + () => + function MockDragAndDropList({ items, renderItem }) { + return ( +
+ {items.map((item, index) => ( +
+ {renderItem(item, index, { isDragging: false, dragHandleProps: {} })} +
+ ))} +
+ ); + } +); + +const baseItem = { + id: 1, + name: "Color", + type: "CheckBoxList", + is_required: false, + minimum_quantity: 0, + maximum_quantity: 0, + values: [ + { id: 101, name: "Red", value: "red", is_default: false, order: 1 }, + { id: 102, name: "Blue", value: "blue", is_default: true, order: 2 } + ] +}; + +const defaultProps = { + itemIdx: 0, + baseName: "meta_fields", + onAdd: jest.fn(), + onDelete: jest.fn(), + onDeleteValue: jest.fn(), + entityId: 1, + isAddDisabled: false +}; + +describe("AdditionalInputV2 + MetaFieldValuesV2 integration", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders the field's real values via MetaFieldValuesV2", () => { + render( + +
+ + +
+ ); + + expect( + screen.getAllByPlaceholderText("meta_fields.placeholders.name") + ).toHaveLength(2); + }); + + test("adding a value through MetaFieldValuesV2 updates meta_fields at the right fieldIndex", async () => { + const TestWrapper = () => { + const { values } = useFormikContext(); + const field = values.meta_fields[1]; + return ( + <> + +
{field.values.length}
+ + ); + }; + + const otherField = { ...baseItem, id: 2, name: "Size", values: [] }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("values-count")).toHaveTextContent("0"); + + await userEvent.click(screen.getByRole("button", { name: /add_value/i })); + + await waitFor(() => { + expect(screen.getByTestId("values-count")).toHaveTextContent("1"); + }); + }); + + test("removing a value confirms and calls onDeleteValue with entityId/field/value ids", async () => { + showConfirmDialog.mockResolvedValue(true); + const onDeleteValue = jest.fn().mockResolvedValue(); + + render( + +
+ + +
+ ); + + const closeButton = screen.getAllByTestId("CloseIcon")[0].closest("button"); + await userEvent.click(closeButton); + + await waitFor(() => { + expect(onDeleteValue).toHaveBeenCalledWith(1, 1, 101); + }); + }); + + test("toggling is_default in MetaFieldValuesV2 keeps only one value default", async () => { + render( + +
+ + +
+ ); + + const checkboxes = screen.getAllByRole("checkbox"); + // is_required checkbox is first, then the two value defaults + const [redDefault, blueDefault] = checkboxes.slice(1); + + expect(blueDefault).toBeChecked(); + expect(redDefault).not.toBeChecked(); + + await userEvent.click(redDefault); + + expect(redDefault).toBeChecked(); + expect(blueDefault).not.toBeChecked(); + }); +}); diff --git a/src/components/mui/__tests__/meta-field-values-v2.test.js b/src/components/mui/__tests__/meta-field-values-v2.test.js new file mode 100644 index 00000000..5545a105 --- /dev/null +++ b/src/components/mui/__tests__/meta-field-values-v2.test.js @@ -0,0 +1,332 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik, Form, useFormikContext } from "formik"; +import "@testing-library/jest-dom"; +import MetaFieldValuesV2 from "../formik-inputs/additional-input/meta-field-values-v2"; +import showConfirmDialog from "../showConfirmDialog"; + +// Mocks +jest.mock("../showConfirmDialog", () => jest.fn()); + +jest.mock( + "../DragNDropList", + () => + function MockDragAndDropList({ items, renderItem }) { + return ( +
+ {items.map((item, index) => ( +
+ {renderItem(item, index, { isDragging: false, dragHandleProps: {} })} +
+ ))} +
+ ); + } +); + +// Helper function to render the component with Formik +const renderWithFormik = (props, initialValues = { meta_fields: [] }) => + render( + +
+ + +
+ ); + +describe("MetaFieldValuesV2", () => { + const defaultField = { + id: 1, + name: "Test Field", + type: "CheckBoxList", + values: [ + { id: 101, name: "Option 1", value: "opt1", is_default: false, order: 1 }, + { id: 102, name: "Option 2", value: "opt2", is_default: true, order: 2 } + ] + }; + + const defaultProps = { + field: defaultField, + fieldIndex: 0, + baseName: "meta_fields", + onMetaFieldTypeValueDeleted: jest.fn(), + entityId: 1 + }; + + const defaultInitialValues = { + meta_fields: [defaultField] + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + test("renders all field values sorted by order prop", () => { + const fieldWithUnorderedValues = { + ...defaultField, + values: [ + { id: 103, name: "Option 3", value: "opt3", order: 3 }, + { id: 101, name: "Option 1", value: "opt1", order: 1 }, + { id: 102, name: "Option 2", value: "opt2", order: 2 } + ] + }; + + renderWithFormik( + { ...defaultProps, field: fieldWithUnorderedValues }, + { meta_fields: [fieldWithUnorderedValues] } + ); + + // verify all values are rendered + const items = screen.getAllByPlaceholderText( + "meta_fields.placeholders.name" + ); + expect(items).toHaveLength(3); + + // verify the values are rendered using the order prop + expect(items[0]).toHaveValue("Option 1"); + expect(items[1]).toHaveValue("Option 2"); + expect(items[2]).toHaveValue("Option 3"); + }); + }); + + describe("handleAddValue", () => { + test("adds a new empty value when add button is clicked", async () => { + // Componente wrapper que sincroniza field con Formik + const TestWrapper = () => { + const { values } = useFormikContext(); + const field = values.meta_fields[0]; + return ( + + ); + }; + + render( + +
+ + +
+ ); + + // Verificar cantidad inicial + const initialInputs = screen.getAllByPlaceholderText( + "meta_fields.placeholders.name" + ); + expect(initialInputs).toHaveLength(2); + + // Click en agregar + const addButton = screen.getByRole("button", { name: /add/i }); + await userEvent.click(addButton); + + // Esperar actualización + await waitFor(() => { + const updatedInputs = screen.getAllByPlaceholderText( + "meta_fields.placeholders.name" + ); + expect(updatedInputs).toHaveLength(3); + }); + }); + }); + + describe("isMetafieldValueIncomplete", () => { + test("disables add button when a value name is empty", () => { + const fieldWithIncomplete = { + ...defaultField, + values: [ + { id: 101, name: "", value: "opt1", is_default: false, order: 1 } + ] + }; + + renderWithFormik( + { ...defaultProps, field: fieldWithIncomplete }, + { meta_fields: [fieldWithIncomplete] } + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).toBeDisabled(); + }); + + test("disables add button when a value is empty", () => { + const fieldWithIncomplete = { + ...defaultField, + values: [ + { id: 101, name: "Option", value: "", is_default: false, order: 1 } + ] + }; + + renderWithFormik( + { ...defaultProps, field: fieldWithIncomplete }, + { meta_fields: [fieldWithIncomplete] } + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).toBeDisabled(); + }); + + test("enables add button when all values are complete", () => { + renderWithFormik(defaultProps, defaultInitialValues); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).not.toBeDisabled(); + }); + + test("enables add button when there are no values", () => { + const fieldWithNoValues = { ...defaultField, values: [] }; + + renderWithFormik( + { ...defaultProps, field: fieldWithNoValues }, + { meta_fields: [fieldWithNoValues] } + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).not.toBeDisabled(); + }); + }); + + describe("handleDefaultChange", () => { + test("only one value can be default at a time", async () => { + renderWithFormik(defaultProps, defaultInitialValues); + + const checkboxes = screen.getAllByRole("checkbox"); + + // Option 2 is default + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[0]).not.toBeChecked(); + + // click on Option 1 + await userEvent.click(checkboxes[0]); + + // Option 1 should be checked and Option 2 unchecked + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + }); + }); + + describe("handleRemoveValue", () => { + test("shows confirmation dialog when remove is clicked", async () => { + showConfirmDialog.mockResolvedValue(false); + renderWithFormik(defaultProps, defaultInitialValues); + + const closeIcons = screen.getAllByTestId("CloseIcon"); + const closeButton = closeIcons[0].closest("button"); + await userEvent.click(closeButton); + + expect(showConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.any(String), + type: "warning" + }) + ); + }); + + test("calls API and removes from UI when value has id", async () => { + const mockOnDelete = jest.fn().mockResolvedValue(); + showConfirmDialog.mockResolvedValue(true); + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + const field = values.meta_fields[0]; + return ( + + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getAllByTestId("CloseIcon")).toHaveLength(2); + + const closeButton = screen + .getAllByTestId("CloseIcon")[0] + .closest("button"); + await userEvent.click(closeButton); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith(1, 1, 101); + expect(screen.getAllByTestId("CloseIcon")).toHaveLength(1); + }); + }); + + test("removes from UI without API call when value has no id", async () => { + const mockOnDelete = jest.fn(); + showConfirmDialog.mockResolvedValue(true); + + const fieldWithoutIds = { + ...defaultField, + values: [ + { name: "Option 1", value: "opt1", is_default: false, order: 1 }, + { name: "Option 2", value: "opt2", is_default: false, order: 2 } + ] + }; + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + const field = values.meta_fields[0]; + return ( + + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getAllByTestId("CloseIcon")).toHaveLength(2); + + const closeButton = screen + .getAllByTestId("CloseIcon")[0] + .closest("button"); + await userEvent.click(closeButton); + + await waitFor(() => { + expect(mockOnDelete).not.toHaveBeenCalled(); + expect(screen.getAllByTestId("CloseIcon")).toHaveLength(1); + }); + }); + }); +}); diff --git a/src/components/mui/__tests__/mui-table-sortable-v2.test.js b/src/components/mui/__tests__/mui-table-sortable-v2.test.js new file mode 100644 index 00000000..7c6640d8 --- /dev/null +++ b/src/components/mui/__tests__/mui-table-sortable-v2.test.js @@ -0,0 +1,182 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../showConfirmDialog", () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock("@dnd-kit/core", () => ({ + DndContext: ({ children }) => children, + closestCenter: jest.fn(), + KeyboardSensor: function KeyboardSensor() {}, + PointerSensor: function PointerSensor() {}, + useSensor: jest.fn(() => ({})), + useSensors: jest.fn(() => []) +})); + +jest.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }) => children, + sortableKeyboardCoordinates: jest.fn(), + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: jest.fn(), + transform: null, + transition: null, + isDragging: false + }), + verticalListSortingStrategy: jest.fn(), + arrayMove: (arr, from, to) => { + const result = [...arr]; + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; + } +})); + +jest.mock("@dnd-kit/utilities", () => ({ + CSS: { Transform: { toString: () => "" } } +})); + +jest.mock("@mui/material/TablePagination", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ count, page, onPageChange, onRowsPerPageChange, rowsPerPageOptions }) => ( +
+ count:{count} + + +
+ ) + }; +}); + +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import MuiTableSortableV2 from "../sortable-table/mui-table-sortable-v2"; +import showConfirmDialog from "../showConfirmDialog"; + +const columns = [ + { columnKey: "name", header: "Name", sortable: true }, + { columnKey: "role", header: "Role", sortable: false } +]; + +const data = [ + { id: 1, name: "Alice", role: "Dev", order: 1 }, + { id: 2, name: "Bob", role: "PM", order: 2 } +]; + +const setup = (overrides = {}) => { + const props = { + columns, + data, + totalRows: 2, + perPage: 10, + currentPage: 1, + onPageChange: jest.fn(), + onPerPageChange: jest.fn(), + onSort: jest.fn(), + options: { sortCol: "name", sortDir: 1 }, + ...overrides + }; + render(); + return props; +}; + +describe("MuiTableSortableV2", () => { + beforeEach(() => jest.clearAllMocks()); + + test("renders column headers", () => { + setup(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Role")).toBeInTheDocument(); + }); + + test("renders data rows", () => { + setup(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + test("shows no-items message when data is empty", () => { + setup({ data: [] }); + expect(screen.getByText("mui_table.no_items")).toBeInTheDocument(); + }); + + test("renders edit button when onEdit is provided", () => { + const onEdit = jest.fn(); + setup({ onEdit }); + expect(screen.getAllByRole("button").length).toBeGreaterThan(0); + }); + + test("calls onEdit when edit button is clicked", async () => { + const onEdit = jest.fn(); + setup({ onEdit }); + const buttons = screen.getAllByRole("button"); + // buttons[0] is the sort label button for the sortable "Name" column; + // buttons[1] is the first edit button (row 1) + await userEvent.click(buttons[1]); + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + }); + + test("calls showConfirmDialog and onDelete when delete is confirmed", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(true); + setup({ onDelete }); + const buttons = screen.getAllByRole("button"); + // buttons[0] is the sort label button; buttons[1] is the first delete button (row 1) + await userEvent.click(buttons[1]); + await new Promise((r) => setTimeout(r, 0)); + expect(showConfirmDialog).toHaveBeenCalled(); + expect(onDelete).toHaveBeenCalledWith(1); + }); + + test("renders pagination", () => { + setup(); + expect(screen.getByTestId("pagination")).toBeInTheDocument(); + }); + + test("calls onPageChange when next page is clicked", async () => { + const onPageChange = jest.fn(); + setup({ onPageChange, currentPage: 1 }); + await userEvent.click( + within(screen.getByTestId("pagination")).getByRole("button", { + name: "next-page" + }) + ); + expect(onPageChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js new file mode 100644 index 00000000..fff1fcf5 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js @@ -0,0 +1,123 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect } from "react"; +import { useFormikContext, getIn } from "formik"; +import T from "i18n-react"; +import AdditionalInputV2 from "./additional-input-v2"; +import showConfirmDialog from "../../showConfirmDialog"; +import { METAFIELD_TYPES_WITH_OPTIONS } from "../../../../utils/constants"; + +const DEFAULT_META_FIELD = { + name: "", + type: "", + is_required: false, + minimum_quantity: 0, + maximum_quantity: 0, + values: [] +}; + +const AdditionalInputListV2 = ({ name, onDelete, onDeleteValue, entityId }) => { + const { values, setFieldValue, errors, setFieldTouched } = useFormikContext(); + + const metaFields = values[name] || []; + + useEffect(() => { + if (metaFields.length === 0) { + setFieldValue(name, [ + { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } + ]); + } + }, [metaFields.length]); + + const handleAddItem = () => { + setFieldValue(name, [ + ...metaFields, + { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } + ]); + }; + + const handleRemove = async (item, index) => { + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate("additional_inputs.delete_warning")} ${ + item.name + }`, + type: "warning", + confirmButtonColor: "#DD6B55", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (!isConfirmed) return; + + const removeFromUI = () => { + const newValues = metaFields.filter((_, idx) => idx !== index); + if (newValues.length === 0) { + newValues.push({ ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` }); + } + setFieldValue(name, newValues); + setFieldTouched(name, [], false); + }; + + if (item.id && onDelete) { + onDelete(entityId, item.id) + .then(() => removeFromUI()) + .catch((err) => console.error("Error deleting field from API", err)); + } else { + removeFromUI(); + } + }; + + const areMetafieldsIncomplete = () => { + const fieldErrors = getIn(errors, name); + if (fieldErrors && Array.isArray(fieldErrors)) { + const hasRealErrors = fieldErrors.some( + (err) => err && Object.keys(err).length > 0 + ); + if (hasRealErrors) return true; + } + + return metaFields.some((field) => { + if (!field.name?.trim() || !field.type) return true; + if (METAFIELD_TYPES_WITH_OPTIONS.includes(field.type)) { + if (!field.values || field.values.length === 0) return true; + const hasIncompleteValues = field.values.some( + (v) => !v.name?.trim() || !v.value?.trim() + ); + if (hasIncompleteValues) return true; + } + + return false; + }); + }; + + return ( + <> + {metaFields.map((item, itemIdx) => ( + + ))} + + ); +}; + +export default AdditionalInputListV2; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-v2.js new file mode 100644 index 00000000..be85ab63 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-v2.js @@ -0,0 +1,207 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { + Box, + Button, + Divider, + FormHelperText, + Grid2, + InputLabel, + MenuItem +} from "@mui/material"; +import { useFormikContext, getIn } from "formik"; +import T from "i18n-react/dist/i18n-react"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import MetaFieldValuesV2 from "./meta-field-values-v2"; +import MuiFormikTextField from "../mui-formik-textfield"; +import MuiFormikSelect from "../mui-formik-select"; +import MuiFormikCheckbox from "../mui-formik-checkbox"; +import { + METAFIELD_TYPES, + METAFIELD_TYPES_WITH_OPTIONS +} from "../../../../utils/constants"; + +const AdditionalInputV2 = ({ + item, + itemIdx, + baseName, + onAdd, + onDelete, + onDeleteValue, + entityId, + isAddDisabled +}) => { + const { errors, touched, values, setFieldValue } = useFormikContext(); + + const buildFieldName = (fieldName) => `${baseName}[${itemIdx}].${fieldName}`; + const currentType = getIn(values, buildFieldName("type")); + + const handleTypeChange = (e) => { + const newType = e.target.value; + setFieldValue(buildFieldName("type"), newType); + if (!METAFIELD_TYPES_WITH_OPTIONS.includes(newType)) { + setFieldValue(buildFieldName("values"), []); + } + }; + + const fieldErrors = getIn(errors, `${baseName}[${itemIdx}]`); + const fieldTouched = getIn(touched, `${baseName}[${itemIdx}]`); + + const showValuesError = + fieldTouched?.values && + fieldErrors?.values && + typeof fieldErrors.values === "string"; + + return ( + + + + + + + {T.translate("additional_inputs.title")} + + + + + + {T.translate("additional_inputs.type")} + + + {METAFIELD_TYPES.map((fieldType) => ( + + {fieldType} + + ))} + + + + + + + {METAFIELD_TYPES_WITH_OPTIONS.includes(currentType) && ( + <> + + + {showValuesError && ( + + {fieldErrors.values} + + )} + + )} + {currentType === "Quantity" && ( + + + + + + + + + )} + + + + + + + + + + ); +}; + +export default AdditionalInputV2; diff --git a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js new file mode 100644 index 00000000..ea2cc85d --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js @@ -0,0 +1,201 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import T from "i18n-react/dist/i18n-react"; +import { useFormikContext } from "formik"; +import { Box, Button, Grid2, Divider, IconButton } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import AddIcon from "@mui/icons-material/Add"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import DragAndDropList from "../../DragNDropList"; +import showConfirmDialog from "../../showConfirmDialog"; +import MuiFormikTextField from "../mui-formik-textfield"; +import MuiFormikCheckbox from "../mui-formik-checkbox"; + +const MetaFieldValuesV2 = ({ + field, + fieldIndex, + baseName = "meta_fields", + onMetaFieldTypeValueDeleted, + entityId +}) => { + const { values, setFieldValue } = useFormikContext(); + + const metaFields = values[baseName] || []; + const sortedValues = [...field.values].sort((a, b) => a.order - b.order); + + const buildValueFieldName = (valueIndex, fieldName) => + `${baseName}[${fieldIndex}].values[${valueIndex}].${fieldName}`; + + const onReorder = (newValues) => { + const newMetaFields = [...metaFields]; + newMetaFields[fieldIndex].values = newValues; + setFieldValue(baseName, newMetaFields); + }; + + const handleAddValue = () => { + const newFields = metaFields.map((f, i) => + i === fieldIndex + ? { ...f, values: [...f.values, { value: "", name: "", is_default: false }] } + : f + ); + setFieldValue(baseName, newFields); + }; + + const handleDefaultChange = (valueIndex, checked) => { + const newFields = [...metaFields]; + if (checked) { + newFields[fieldIndex].values.forEach((v) => { + v.is_default = false; + }); + } + newFields[fieldIndex].values[valueIndex].is_default = checked; + setFieldValue(baseName, newFields); + }; + + const isMetafieldValueIncomplete = () => { + if (field.values.length > 0) { + return field.values.some((v) => !v.name?.trim() || !v.value?.trim()); + } + return false; + }; + + const handleRemoveValue = async (metaFieldValue, valueIndex) => { + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: T.translate("meta_fields.delete_value_warning"), + type: "warning", + confirmButtonColor: "#DD6B55", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (!isConfirmed) return; + + const removeValueFromFields = () => { + const newFields = [...metaFields]; + newFields[fieldIndex].values = newFields[fieldIndex].values.filter( + (_, index) => index !== valueIndex + ); + setFieldValue(baseName, newFields); + }; + + if (field.id && metaFieldValue.id && onMetaFieldTypeValueDeleted) { + onMetaFieldTypeValueDeleted(entityId, field.id, metaFieldValue.id).then( + () => removeValueFromFields() + ); + } else { + removeValueFromFields(); + } + }; + + const renderMetaFieldValue = (val, sortedIndex, { isDragging, dragHandleProps } = {}) => { + const originalIndex = field.values.findIndex( + (v) => (v.id && v.id === val.id) || v === val + ); + const valueIndex = originalIndex !== -1 ? originalIndex : sortedIndex; + + return ( + + + + + + + + + + + + handleRemoveValue(val, valueIndex)} + aria-label="remove value" + > + + + ) + }} + /> + + + + handleDefaultChange(valueIndex, e.target.checked) + } + /> + + + + + ); + }; + + return ( + + + + + + + ); +}; + +export default MetaFieldValuesV2; diff --git a/src/components/mui/sortable-table/mui-table-sortable-v2.js b/src/components/mui/sortable-table/mui-table-sortable-v2.js new file mode 100644 index 00000000..f9b26c78 --- /dev/null +++ b/src/components/mui/sortable-table/mui-table-sortable-v2.js @@ -0,0 +1,328 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import * as React from "react"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import TableRow from "@mui/material/TableRow"; +import Paper from "@mui/material/Paper"; +import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; +import { IconButton } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + arrayMove +} from "@dnd-kit/sortable"; +import { visuallyHidden } from "@mui/utils"; + +import styles from "./styles.module.less"; + +import { + DEFAULT_PER_PAGE, + FIFTY_PER_PAGE, + TWENTY_PER_PAGE +} from "../../../utils/constants"; +import showConfirmDialog from "../showConfirmDialog"; +import TableCellContent from "../table/table-content"; +import SortableRow from "./sortable-row"; + +const getRowId = (row, index, idKey) => + row[idKey] !== undefined && row[idKey] !== null + ? String(row[idKey]) + : String(index); + +const MuiTableSortableV2 = ({ + columns = [], + data = [], + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onSort, + options = { sortCol: "", sortDir: 1 }, + getName = (item) => item.name, + onEdit, + onDelete, + deleteDialogTitle = null, + deleteDialogBody = null, + onReorder, + idKey = "id", + updateOrderKey = "order" +}) => { + const handleChangePage = (_, newPage) => { + onPageChange(newPage + 1); + }; + + const handleChangeRowsPerPage = (ev) => { + onPerPageChange(ev.target.value); + }; + + const basePerPageOptions = [ + DEFAULT_PER_PAGE, + TWENTY_PER_PAGE, + FIFTY_PER_PAGE + ]; + + const customPerPageOptions = basePerPageOptions.includes(perPage) + ? basePerPageOptions + : [...basePerPageOptions, perPage].sort((a, b) => a - b); + + const { sortCol, sortDir } = options; + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const handleDragEnd = ({ active, over }) => { + if (!over || active.id === over.id) return; + + const oldIndex = data.findIndex( + (row, i) => getRowId(row, i, idKey) === active.id + ); + const newIndex = data.findIndex( + (row, i) => getRowId(row, i, idKey) === over.id + ); + + if (oldIndex === -1 || newIndex === -1) return; + + const movedItem = data[oldIndex]; + const reordered = arrayMove(data, oldIndex, newIndex); + + // change value based on updateOrderKey + if (updateOrderKey) { + reordered.forEach((item, idx) => { + item[updateOrderKey] = idx + 1; + }); + } + + const movedItemId = movedItem.id; + const newOrder = reordered.find( + (item) => item[idKey || "id"] === movedItemId + )?.[updateOrderKey]; + + onReorder?.(reordered, movedItemId, newOrder); + }; + + const handleDelete = async (item) => { + const isConfirmed = await showConfirmDialog({ + title: deleteDialogTitle || T.translate("general.are_you_sure"), + text: + typeof deleteDialogBody === "function" + ? deleteDialogBody(getName(item)) + : deleteDialogBody || + `${T.translate("general.row_remove_warning")} ${getName(item)}`, + type: "warning", + showCancelButton: true, + confirmButtonColor: "#DD6B55", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (isConfirmed) { + onDelete(item.id); + } + }; + + return ( + + + + + {/* TABLE HEADER */} + + + {columns.map((col) => ( + + {col.sortable ? ( + onSort(col.columnKey, sortDir * -1)} + > + {col.header} + {sortCol === col.columnKey ? ( + + {sortDir === -1 + ? T.translate("mui_table.sorted_desc") + : T.translate("mui_table.sorted_asc")} + + ) : null} + + ) : ( + col.header + )} + + ))} + {onEdit && } + {onDelete && } + {onReorder && } + + + + {/* TABLE BODY */} + + getRowId(row, i, idKey))} + strategy={verticalListSortingStrategy} + > + + {data.map((row, rowIndex) => ( + + {({ dragHandleProps }) => ( + <> + {/* Main content columns */} + {columns.map((col) => ( + + + + ))} + {/* Edit column */} + {onEdit && ( + + onEdit(row)} + sx={{ padding: 0 }} + > + + + + )} + {/* Delete column */} + {onDelete && ( + + handleDelete(row)} + sx={{ padding: 0 }} + > + + + + )} + {/* Re order column */} + {onReorder && ( + + + + + + )} + + )} + + ))} + {data.length === 0 && ( + + + {T.translate("mui_table.no_items")} + + + )} + + + +
+
+ + {/* PAGINATION */} + {onPerPageChange && onPageChange && ( + + )} +
+
+ ); +}; + +export default MuiTableSortableV2; diff --git a/src/components/mui/sortable-table/sortable-row.js b/src/components/mui/sortable-table/sortable-row.js new file mode 100644 index 00000000..3647a7a4 --- /dev/null +++ b/src/components/mui/sortable-table/sortable-row.js @@ -0,0 +1,62 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import TableRow from "@mui/material/TableRow"; + +const SortableRow = ({ id, children }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id }); + + return ( + + {children({ dragHandleProps: { ...listeners, ...attributes } })} + + ); +}; + +SortableRow.propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.func.isRequired +}; + +export default SortableRow; diff --git a/webpack.common.js b/webpack.common.js index 162da07a..92383e3e 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -106,11 +106,14 @@ module.exports = { 'components/mui/editable-table': './src/components/mui/editable-table/mui-table-editable.js', 'components/mui/sortable-table': './src/components/mui/sortable-table/mui-table-sortable.js', 'components/mui/bulk-edit-table': './src/components/mui/BulkEditTable/index.js', + 'components/mui/sortable-table-v2': './src/components/mui/sortable-table/mui-table-sortable-v2.js', 'components/mui/table': './src/components/mui/table/mui-table.js', 'components/mui/table/custom-table-pagination': './src/components/mui/table/CustomTablePagination.js', 'components/mui/table/extra-rows': './src/components/mui/table/extra-rows/index.js', 'components/mui/formik-inputs/additional-input': './src/components/mui/formik-inputs/additional-input/additional-input.js', 'components/mui/formik-inputs/additional-input-list': './src/components/mui/formik-inputs/additional-input/additional-input-list.js', + 'components/mui/formik-inputs/additional-input-v2': './src/components/mui/formik-inputs/additional-input/additional-input-v2.js', + 'components/mui/formik-inputs/additional-input-list-v2': './src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js', 'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js', 'components/mui/formik-inputs/checkbox-group': './src/components/mui/formik-inputs/mui-formik-checkbox-group.js', 'components/mui/formik-inputs/checkbox': './src/components/mui/formik-inputs/mui-formik-checkbox.js', From 5c3ebacb12f2e872a31828d36edf6f93b482f002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Jul 2026 15:29:27 -0300 Subject: [PATCH 02/14] fix: update table cell content dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/sortable-table/mui-table-sortable-v2.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/sortable-table/mui-table-sortable-v2.js b/src/components/mui/sortable-table/mui-table-sortable-v2.js index f9b26c78..08f7aaf5 100644 --- a/src/components/mui/sortable-table/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table/mui-table-sortable-v2.js @@ -51,8 +51,8 @@ import { TWENTY_PER_PAGE } from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; -import TableCellContent from "../table/table-content"; import SortableRow from "./sortable-row"; +import TableCellContent from "../table/table-content"; const getRowId = (row, index, idKey) => row[idKey] !== undefined && row[idKey] !== null From e9dcffd3ae15add2b973b37c8a52c4553af6ae95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Jul 2026 15:48:36 -0300 Subject: [PATCH 03/14] fix: adjust code from PR comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 2 +- .../additional-input/meta-field-values-v2.js | 37 ++++++++++----- .../sortable-table/mui-table-sortable-v2.js | 47 ++++++++++--------- .../mui/sortable-table/sortable-row.js | 2 +- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/components/index.js b/src/components/index.js index 17b24c0f..32ab657f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -141,6 +141,6 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) // export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities -// export {default as MuiSortableTableV2} from './mui/sortable-table/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +// export {default as MuiTableSortableV2} from './mui/sortable-table/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities // export {default as MuiAdditionalInputV2} from './mui/formik-inputs/additional-input/additional-input-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) // export {default as MuiAdditionalInputListV2} from './mui/formik-inputs/additional-input/additional-input-list-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) diff --git a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js index ea2cc85d..590d5d9b 100644 --- a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js +++ b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js @@ -39,28 +39,38 @@ const MetaFieldValuesV2 = ({ `${baseName}[${fieldIndex}].values[${valueIndex}].${fieldName}`; const onReorder = (newValues) => { - const newMetaFields = [...metaFields]; - newMetaFields[fieldIndex].values = newValues; + const newMetaFields = metaFields.map((f, i) => + i === fieldIndex ? { ...f, values: newValues } : f + ); setFieldValue(baseName, newMetaFields); }; const handleAddValue = () => { const newFields = metaFields.map((f, i) => i === fieldIndex - ? { ...f, values: [...f.values, { value: "", name: "", is_default: false }] } + ? { + ...f, + values: [ + ...f.values, + { value: "", name: "", is_default: false, order: f.values.length + 1 } + ] + } : f ); setFieldValue(baseName, newFields); }; const handleDefaultChange = (valueIndex, checked) => { - const newFields = [...metaFields]; - if (checked) { - newFields[fieldIndex].values.forEach((v) => { - v.is_default = false; - }); - } - newFields[fieldIndex].values[valueIndex].is_default = checked; + const newFields = metaFields.map((f, i) => { + if (i !== fieldIndex) return f; + return { + ...f, + values: f.values.map((v, vi) => { + if (checked) return { ...v, is_default: vi === valueIndex }; + return vi === valueIndex ? { ...v, is_default: false } : v; + }) + }; + }); setFieldValue(baseName, newFields); }; @@ -83,9 +93,10 @@ const MetaFieldValuesV2 = ({ if (!isConfirmed) return; const removeValueFromFields = () => { - const newFields = [...metaFields]; - newFields[fieldIndex].values = newFields[fieldIndex].values.filter( - (_, index) => index !== valueIndex + const newFields = metaFields.map((f, i) => + i === fieldIndex + ? { ...f, values: f.values.filter((_, index) => index !== valueIndex) } + : f ); setFieldValue(baseName, newFields); }; diff --git a/src/components/mui/sortable-table/mui-table-sortable-v2.js b/src/components/mui/sortable-table/mui-table-sortable-v2.js index 08f7aaf5..d5e4c600 100644 --- a/src/components/mui/sortable-table/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table/mui-table-sortable-v2.js @@ -52,7 +52,7 @@ import { } from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; import SortableRow from "./sortable-row"; -import TableCellContent from "../table/table-content"; +import TableCellContent from "../table/table-cell-content"; const getRowId = (row, index, idKey) => row[idKey] !== undefined && row[idKey] !== null @@ -83,7 +83,7 @@ const MuiTableSortableV2 = ({ }; const handleChangeRowsPerPage = (ev) => { - onPerPageChange(ev.target.value); + onPerPageChange(parseInt(ev.target.value, 10)); }; const basePerPageOptions = [ @@ -116,19 +116,17 @@ const MuiTableSortableV2 = ({ if (oldIndex === -1 || newIndex === -1) return; const movedItem = data[oldIndex]; - const reordered = arrayMove(data, oldIndex, newIndex); + const movedItemId = movedItem?.[idKey] ?? movedItem?.id; - // change value based on updateOrderKey - if (updateOrderKey) { - reordered.forEach((item, idx) => { - item[updateOrderKey] = idx + 1; - }); - } + const reordered = arrayMove(data, oldIndex, newIndex).map((item, idx) => + updateOrderKey ? { ...item, [updateOrderKey]: idx + 1 } : item + ); - const movedItemId = movedItem.id; - const newOrder = reordered.find( - (item) => item[idKey || "id"] === movedItemId - )?.[updateOrderKey]; + const newOrder = updateOrderKey + ? reordered.find((item) => (item[idKey] ?? item.id) === movedItemId)?.[ + updateOrderKey + ] + : undefined; onReorder?.(reordered, movedItemId, newOrder); }; @@ -140,7 +138,7 @@ const MuiTableSortableV2 = ({ typeof deleteDialogBody === "function" ? deleteDialogBody(getName(item)) : deleteDialogBody || - `${T.translate("general.row_remove_warning")} ${getName(item)}`, + `${T.translate("general.row_remove_warning")} ${getName(item)}`, type: "warning", showCancelButton: true, confirmButtonColor: "#DD6B55", @@ -148,7 +146,7 @@ const MuiTableSortableV2 = ({ }); if (isConfirmed) { - onDelete(item.id); + onDelete(item[idKey] ?? item.id); } }; @@ -226,9 +224,8 @@ const MuiTableSortableV2 = ({ @@ -284,9 +281,15 @@ const MuiTableSortableV2 = ({ ))} {data.length === 0 && ( - - {T.translate("mui_table.no_items")} - + )} @@ -299,7 +302,7 @@ const MuiTableSortableV2 = ({ {onPerPageChange && onPageChange && ( { width: "100%", tableLayout: "fixed", backgroundColor: "#f0f0f0", - transform: "scale(1.01)", + transform: `${CSS.Transform.toString(transform)} scale(1.01)`, boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)" } : {}) From 4b2862ea66f7c9c38d78ebba1eca674a539b0915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 8 Jul 2026 16:06:02 -0300 Subject: [PATCH 04/14] fix: rollback missing text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/sortable-table/mui-table-sortable-v2.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/mui/sortable-table/mui-table-sortable-v2.js b/src/components/mui/sortable-table/mui-table-sortable-v2.js index d5e4c600..2868297b 100644 --- a/src/components/mui/sortable-table/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table/mui-table-sortable-v2.js @@ -289,7 +289,9 @@ const MuiTableSortableV2 = ({ (onReorder ? 1 : 0) } align="center" - > + > + {T.translate("mui_table.no_items")} + )} From 368013ad23d864b22ea362306b6b218c94e1c25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 14 Jul 2026 03:02:47 -0300 Subject: [PATCH 05/14] fix: update sortable table directory, clean code and use prop for v2 component on additional input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 6 +- .../mui/__tests__/additional-input-v2.test.js | 165 -------------- .../mui/__tests__/additional-input.test.js | 34 +++ .../__tests__/mui-table-sortable-v2.test.js | 2 +- .../additional-input-list-v2.js | 123 ----------- .../additional-input/additional-input-list.js | 9 +- .../additional-input/additional-input-v2.js | 207 ------------------ .../additional-input/additional-input.js | 7 +- .../mui-table-sortable-v2.js | 0 .../sortable-row.js | 0 .../mui/sortable-table-v2/styles.module.less | 14 ++ webpack.common.js | 2 +- 12 files changed, 65 insertions(+), 504 deletions(-) delete mode 100644 src/components/mui/__tests__/additional-input-v2.test.js delete mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js delete mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-v2.js rename src/components/mui/{sortable-table => sortable-table-v2}/mui-table-sortable-v2.js (100%) rename src/components/mui/{sortable-table => sortable-table-v2}/sortable-row.js (100%) create mode 100644 src/components/mui/sortable-table-v2/styles.module.less diff --git a/src/components/index.js b/src/components/index.js index 32ab657f..4f3b6285 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -138,9 +138,7 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd // export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd // export {default as MuiStripePayment} from './mui/StripePayment' // @stripe/react-stripe-js, @stripe/stripe-js -// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) -// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) +// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop +// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities // export {default as MuiTableSortableV2} from './mui/sortable-table/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities -// export {default as MuiAdditionalInputV2} from './mui/formik-inputs/additional-input/additional-input-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) -// export {default as MuiAdditionalInputListV2} from './mui/formik-inputs/additional-input/additional-input-list-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities (via MuiDragNDropList) diff --git a/src/components/mui/__tests__/additional-input-v2.test.js b/src/components/mui/__tests__/additional-input-v2.test.js deleted file mode 100644 index 9d9aa402..00000000 --- a/src/components/mui/__tests__/additional-input-v2.test.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright 2026 OpenStack Foundation - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * */ - -// Integration coverage between AdditionalInputV2 and the real MetaFieldValuesV2 -// (not mocked here, unlike additional-input.test.js) to verify baseName/fieldIndex -// wiring actually reaches the meta-field-values actions through the parent. - -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Formik, Form, useFormikContext } from "formik"; -import "@testing-library/jest-dom"; -import AdditionalInputV2 from "../formik-inputs/additional-input/additional-input-v2"; -import showConfirmDialog from "../showConfirmDialog"; - -jest.mock("../showConfirmDialog", () => jest.fn()); - -jest.mock( - "../DragNDropList", - () => - function MockDragAndDropList({ items, renderItem }) { - return ( -
- {items.map((item, index) => ( -
- {renderItem(item, index, { isDragging: false, dragHandleProps: {} })} -
- ))} -
- ); - } -); - -const baseItem = { - id: 1, - name: "Color", - type: "CheckBoxList", - is_required: false, - minimum_quantity: 0, - maximum_quantity: 0, - values: [ - { id: 101, name: "Red", value: "red", is_default: false, order: 1 }, - { id: 102, name: "Blue", value: "blue", is_default: true, order: 2 } - ] -}; - -const defaultProps = { - itemIdx: 0, - baseName: "meta_fields", - onAdd: jest.fn(), - onDelete: jest.fn(), - onDeleteValue: jest.fn(), - entityId: 1, - isAddDisabled: false -}; - -describe("AdditionalInputV2 + MetaFieldValuesV2 integration", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("renders the field's real values via MetaFieldValuesV2", () => { - render( - -
- - -
- ); - - expect( - screen.getAllByPlaceholderText("meta_fields.placeholders.name") - ).toHaveLength(2); - }); - - test("adding a value through MetaFieldValuesV2 updates meta_fields at the right fieldIndex", async () => { - const TestWrapper = () => { - const { values } = useFormikContext(); - const field = values.meta_fields[1]; - return ( - <> - -
{field.values.length}
- - ); - }; - - const otherField = { ...baseItem, id: 2, name: "Size", values: [] }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("values-count")).toHaveTextContent("0"); - - await userEvent.click(screen.getByRole("button", { name: /add_value/i })); - - await waitFor(() => { - expect(screen.getByTestId("values-count")).toHaveTextContent("1"); - }); - }); - - test("removing a value confirms and calls onDeleteValue with entityId/field/value ids", async () => { - showConfirmDialog.mockResolvedValue(true); - const onDeleteValue = jest.fn().mockResolvedValue(); - - render( - -
- - -
- ); - - const closeButton = screen.getAllByTestId("CloseIcon")[0].closest("button"); - await userEvent.click(closeButton); - - await waitFor(() => { - expect(onDeleteValue).toHaveBeenCalledWith(1, 1, 101); - }); - }); - - test("toggling is_default in MetaFieldValuesV2 keeps only one value default", async () => { - render( - -
- - -
- ); - - const checkboxes = screen.getAllByRole("checkbox"); - // is_required checkbox is first, then the two value defaults - const [redDefault, blueDefault] = checkboxes.slice(1); - - expect(blueDefault).toBeChecked(); - expect(redDefault).not.toBeChecked(); - - await userEvent.click(redDefault); - - expect(redDefault).toBeChecked(); - expect(blueDefault).not.toBeChecked(); - }); -}); diff --git a/src/components/mui/__tests__/additional-input.test.js b/src/components/mui/__tests__/additional-input.test.js index b64ea51a..6e8bbfed 100644 --- a/src/components/mui/__tests__/additional-input.test.js +++ b/src/components/mui/__tests__/additional-input.test.js @@ -27,6 +27,14 @@ jest.mock( } ); +jest.mock( + "../formik-inputs/additional-input/meta-field-values-v2", + () => + function MockMetaFieldValuesV2() { + return
MetaFieldValuesV2
; + } +); + // Helper function to render the component with Formik const renderWithFormik = (props, initialValues = { meta_fields: [] }) => render( @@ -130,6 +138,32 @@ describe("AdditionalInput", () => { expect(screen.queryByTestId("meta-field-values")).not.toBeInTheDocument(); }); + test("shows MetaFieldValues (v1) instead of MetaFieldValuesV2 by default", () => { + const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; + + renderWithFormik( + { ...defaultProps, item: itemWithOptions }, + { meta_fields: [itemWithOptions] } + ); + + expect(screen.getByTestId("meta-field-values")).toBeInTheDocument(); + expect( + screen.queryByTestId("meta-field-values-v2") + ).not.toBeInTheDocument(); + }); + + test("shows MetaFieldValuesV2 instead of MetaFieldValues when useV2 is true", () => { + const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; + + renderWithFormik( + { ...defaultProps, item: itemWithOptions, useV2: true }, + { meta_fields: [itemWithOptions] } + ); + + expect(screen.getByTestId("meta-field-values-v2")).toBeInTheDocument(); + expect(screen.queryByTestId("meta-field-values")).not.toBeInTheDocument(); + }); + test("shows quantity fields when type is Quantity", () => { const itemQuantity = { ...defaultItem, type: "Quantity" }; diff --git a/src/components/mui/__tests__/mui-table-sortable-v2.test.js b/src/components/mui/__tests__/mui-table-sortable-v2.test.js index 7c6640d8..a441c971 100644 --- a/src/components/mui/__tests__/mui-table-sortable-v2.test.js +++ b/src/components/mui/__tests__/mui-table-sortable-v2.test.js @@ -86,7 +86,7 @@ import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; -import MuiTableSortableV2 from "../sortable-table/mui-table-sortable-v2"; +import MuiTableSortableV2 from "../sortable-table-v2/mui-table-sortable-v2"; import showConfirmDialog from "../showConfirmDialog"; const columns = [ diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js deleted file mode 100644 index fff1fcf5..00000000 --- a/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright 2026 OpenStack Foundation - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * */ - -import React, { useEffect } from "react"; -import { useFormikContext, getIn } from "formik"; -import T from "i18n-react"; -import AdditionalInputV2 from "./additional-input-v2"; -import showConfirmDialog from "../../showConfirmDialog"; -import { METAFIELD_TYPES_WITH_OPTIONS } from "../../../../utils/constants"; - -const DEFAULT_META_FIELD = { - name: "", - type: "", - is_required: false, - minimum_quantity: 0, - maximum_quantity: 0, - values: [] -}; - -const AdditionalInputListV2 = ({ name, onDelete, onDeleteValue, entityId }) => { - const { values, setFieldValue, errors, setFieldTouched } = useFormikContext(); - - const metaFields = values[name] || []; - - useEffect(() => { - if (metaFields.length === 0) { - setFieldValue(name, [ - { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } - ]); - } - }, [metaFields.length]); - - const handleAddItem = () => { - setFieldValue(name, [ - ...metaFields, - { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } - ]); - }; - - const handleRemove = async (item, index) => { - const isConfirmed = await showConfirmDialog({ - title: T.translate("general.are_you_sure"), - text: `${T.translate("additional_inputs.delete_warning")} ${ - item.name - }`, - type: "warning", - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }); - - if (!isConfirmed) return; - - const removeFromUI = () => { - const newValues = metaFields.filter((_, idx) => idx !== index); - if (newValues.length === 0) { - newValues.push({ ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` }); - } - setFieldValue(name, newValues); - setFieldTouched(name, [], false); - }; - - if (item.id && onDelete) { - onDelete(entityId, item.id) - .then(() => removeFromUI()) - .catch((err) => console.error("Error deleting field from API", err)); - } else { - removeFromUI(); - } - }; - - const areMetafieldsIncomplete = () => { - const fieldErrors = getIn(errors, name); - if (fieldErrors && Array.isArray(fieldErrors)) { - const hasRealErrors = fieldErrors.some( - (err) => err && Object.keys(err).length > 0 - ); - if (hasRealErrors) return true; - } - - return metaFields.some((field) => { - if (!field.name?.trim() || !field.type) return true; - if (METAFIELD_TYPES_WITH_OPTIONS.includes(field.type)) { - if (!field.values || field.values.length === 0) return true; - const hasIncompleteValues = field.values.some( - (v) => !v.name?.trim() || !v.value?.trim() - ); - if (hasIncompleteValues) return true; - } - - return false; - }); - }; - - return ( - <> - {metaFields.map((item, itemIdx) => ( - - ))} - - ); -}; - -export default AdditionalInputListV2; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list.js b/src/components/mui/formik-inputs/additional-input/additional-input-list.js index a629a302..15314d1e 100644 --- a/src/components/mui/formik-inputs/additional-input/additional-input-list.js +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list.js @@ -27,7 +27,13 @@ const DEFAULT_META_FIELD = { values: [] }; -const AdditionalInputList = ({ name, onDelete, onDeleteValue, entityId }) => { +const AdditionalInputList = ({ + name, + onDelete, + onDeleteValue, + entityId, + useV2 = false +}) => { const { values, setFieldValue, errors, setFieldTouched } = useFormikContext(); const metaFields = values[name] || []; @@ -114,6 +120,7 @@ const AdditionalInputList = ({ name, onDelete, onDeleteValue, entityId }) => { onDeleteValue={onDeleteValue} entityId={entityId} isAddDisabled={areMetafieldsIncomplete()} + useV2={useV2} /> ))} diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-v2.js deleted file mode 100644 index be85ab63..00000000 --- a/src/components/mui/formik-inputs/additional-input/additional-input-v2.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Copyright 2026 OpenStack Foundation - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * */ - -import React from "react"; -import { - Box, - Button, - Divider, - FormHelperText, - Grid2, - InputLabel, - MenuItem -} from "@mui/material"; -import { useFormikContext, getIn } from "formik"; -import T from "i18n-react/dist/i18n-react"; -import DeleteIcon from "@mui/icons-material/Delete"; -import AddIcon from "@mui/icons-material/Add"; -import MetaFieldValuesV2 from "./meta-field-values-v2"; -import MuiFormikTextField from "../mui-formik-textfield"; -import MuiFormikSelect from "../mui-formik-select"; -import MuiFormikCheckbox from "../mui-formik-checkbox"; -import { - METAFIELD_TYPES, - METAFIELD_TYPES_WITH_OPTIONS -} from "../../../../utils/constants"; - -const AdditionalInputV2 = ({ - item, - itemIdx, - baseName, - onAdd, - onDelete, - onDeleteValue, - entityId, - isAddDisabled -}) => { - const { errors, touched, values, setFieldValue } = useFormikContext(); - - const buildFieldName = (fieldName) => `${baseName}[${itemIdx}].${fieldName}`; - const currentType = getIn(values, buildFieldName("type")); - - const handleTypeChange = (e) => { - const newType = e.target.value; - setFieldValue(buildFieldName("type"), newType); - if (!METAFIELD_TYPES_WITH_OPTIONS.includes(newType)) { - setFieldValue(buildFieldName("values"), []); - } - }; - - const fieldErrors = getIn(errors, `${baseName}[${itemIdx}]`); - const fieldTouched = getIn(touched, `${baseName}[${itemIdx}]`); - - const showValuesError = - fieldTouched?.values && - fieldErrors?.values && - typeof fieldErrors.values === "string"; - - return ( - - - - - - - {T.translate("additional_inputs.title")} - - - - - - {T.translate("additional_inputs.type")} - - - {METAFIELD_TYPES.map((fieldType) => ( - - {fieldType} - - ))} - - - - - - - {METAFIELD_TYPES_WITH_OPTIONS.includes(currentType) && ( - <> - - - {showValuesError && ( - - {fieldErrors.values} - - )} - - )} - {currentType === "Quantity" && ( - - - - - - - - - )} - - - - - - - - - - ); -}; - -export default AdditionalInputV2; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input.js b/src/components/mui/formik-inputs/additional-input/additional-input.js index fc267572..6021923c 100644 --- a/src/components/mui/formik-inputs/additional-input/additional-input.js +++ b/src/components/mui/formik-inputs/additional-input/additional-input.js @@ -26,6 +26,7 @@ import T from "i18n-react/dist/i18n-react"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; import MetaFieldValues from "./meta-field-values"; +import MetaFieldValuesV2 from "./meta-field-values-v2"; import MuiFormikTextField from "../mui-formik-textfield"; import MuiFormikSelect from "../mui-formik-select"; import MuiFormikCheckbox from "../mui-formik-checkbox"; @@ -42,8 +43,10 @@ const AdditionalInput = ({ onDelete, onDeleteValue, entityId, - isAddDisabled + isAddDisabled, + useV2 = false }) => { + const MetaFieldValuesComponent = useV2 ? MetaFieldValuesV2 : MetaFieldValues; const { errors, touched, values, setFieldValue } = useFormikContext(); const buildFieldName = (fieldName) => `${baseName}[${itemIdx}].${fieldName}`; @@ -118,7 +121,7 @@ const AdditionalInput = ({ {METAFIELD_TYPES_WITH_OPTIONS.includes(currentType) && ( <> - Date: Tue, 14 Jul 2026 03:09:34 -0300 Subject: [PATCH 06/14] fix: remove exports from webpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- webpack.common.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/webpack.common.js b/webpack.common.js index 92a2a3b2..f78669b5 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -112,8 +112,6 @@ module.exports = { 'components/mui/table/extra-rows': './src/components/mui/table/extra-rows/index.js', 'components/mui/formik-inputs/additional-input': './src/components/mui/formik-inputs/additional-input/additional-input.js', 'components/mui/formik-inputs/additional-input-list': './src/components/mui/formik-inputs/additional-input/additional-input-list.js', - 'components/mui/formik-inputs/additional-input-v2': './src/components/mui/formik-inputs/additional-input/additional-input-v2.js', - 'components/mui/formik-inputs/additional-input-list-v2': './src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js', 'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js', 'components/mui/formik-inputs/checkbox-group': './src/components/mui/formik-inputs/mui-formik-checkbox-group.js', 'components/mui/formik-inputs/checkbox': './src/components/mui/formik-inputs/mui-formik-checkbox.js', From d27f993d39d35e68efa4c477a0667e6c03922072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 20 Jul 2026 17:40:36 -0300 Subject: [PATCH 07/14] fix: fix dir, add unit tests and adjust remove and add values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 2 +- .../__tests__/meta-field-values-v2.test.js | 73 +++++++++++++++++++ .../__tests__/mui-table-sortable-v2.test.js | 34 ++++++++- .../additional-input/meta-field-values-v2.js | 24 +++--- .../mui-table-sortable-v2.js | 4 +- 5 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/components/index.js b/src/components/index.js index 4f3b6285..2ac3013e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -141,4 +141,4 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop // export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities -// export {default as MuiTableSortableV2} from './mui/sortable-table/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +// export {default as MuiTableSortableV2} from './mui/sortable-table-v2/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities diff --git a/src/components/mui/__tests__/meta-field-values-v2.test.js b/src/components/mui/__tests__/meta-field-values-v2.test.js index 5545a105..f2c1ad5a 100644 --- a/src/components/mui/__tests__/meta-field-values-v2.test.js +++ b/src/components/mui/__tests__/meta-field-values-v2.test.js @@ -147,6 +147,46 @@ describe("MetaFieldValuesV2", () => { expect(updatedInputs).toHaveLength(3); }); }); + + test("assigns a non-colliding order when adding after removing a middle value", async () => { + const fieldWithGap = { + id: 1, + name: "Test Field", + type: "CheckBoxList", + values: [ + { id: 101, name: "Option 1", value: "opt1", is_default: false, order: 1 }, + { id: 103, name: "Option 3", value: "opt3", is_default: false, order: 3 } + ] + }; + + let latestValues; + const TestWrapper = () => { + const { values } = useFormikContext(); + latestValues = values; + return ( + + ); + }; + + render( + +
+
+ ); + + await userEvent.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + const orders = latestValues.meta_fields[0].values.map((v) => v.order); + expect(new Set(orders).size).toBe(orders.length); // fails today: [1, 3, 3] + }); + }); }); describe("isMetafieldValueIncomplete", () => { @@ -329,4 +369,37 @@ describe("MetaFieldValuesV2", () => { }); }); }); + + test("keeps the value in the list when the delete API call rejects", async () => { + const mockOnDelete = jest.fn().mockRejectedValue(new Error("network error")); + showConfirmDialog.mockResolvedValue(true); + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + const field = values.meta_fields[0]; + return ( + + ); + }; + + render( + +
+
+ ); + + const closeButton = screen.getAllByTestId("CloseIcon")[0].closest("button"); + await userEvent.click(closeButton); + + await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()); + + // today: silently stays at 2 items, no error surfaced + expect(screen.getAllByTestId("CloseIcon")).toHaveLength(2); + }); }); diff --git a/src/components/mui/__tests__/mui-table-sortable-v2.test.js b/src/components/mui/__tests__/mui-table-sortable-v2.test.js index a441c971..789a0b90 100644 --- a/src/components/mui/__tests__/mui-table-sortable-v2.test.js +++ b/src/components/mui/__tests__/mui-table-sortable-v2.test.js @@ -22,7 +22,10 @@ jest.mock("../showConfirmDialog", () => ({ })); jest.mock("@dnd-kit/core", () => ({ - DndContext: ({ children }) => children, + DndContext: ({ children, onDragEnd }) => { + global.__triggerDragEnd = onDragEnd; + return children; + }, closestCenter: jest.fn(), KeyboardSensor: function KeyboardSensor() {}, PointerSensor: function PointerSensor() {}, @@ -179,4 +182,33 @@ describe("MuiTableSortableV2", () => { ); expect(onPageChange).toHaveBeenCalledWith(2); }); + + describe("handleDragEnd", () => { + test("assigns order relative to the current page when reordering on page 2", () => { + const onReorder = jest.fn(); + const page2Data = [ + { id: 11, name: "K", role: "Dev", order: 11 }, + { id: 12, name: "L", role: "PM", order: 12 } + ]; + + setup({ + data: page2Data, + currentPage: 2, + perPage: 10, + totalRows: 20, + onReorder + }); + + global.__triggerDragEnd({ active: { id: "11" }, over: { id: "12" } }); + + expect(onReorder).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 12, order: 11 }), + expect.objectContaining({ id: 11, order: 12 }) + ]), + 11, + 12 + ); + }); + }); }); diff --git a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js index 590d5d9b..50a5a07c 100644 --- a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js +++ b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js @@ -46,16 +46,18 @@ const MetaFieldValuesV2 = ({ }; const handleAddValue = () => { - const newFields = metaFields.map((f, i) => - i === fieldIndex + const newFields = metaFields.map((f, i) => { + const nextOrder = Math.max(0, ...f.values.map((v) => v.order ?? 0)) + 1; + return i === fieldIndex ? { - ...f, - values: [ - ...f.values, - { value: "", name: "", is_default: false, order: f.values.length + 1 } - ] - } + ...f, + values: [ + ...f.values, + { value: "", name: "", is_default: false, order: nextOrder } + ] + } : f + } ); setFieldValue(baseName, newFields); }; @@ -102,9 +104,9 @@ const MetaFieldValuesV2 = ({ }; if (field.id && metaFieldValue.id && onMetaFieldTypeValueDeleted) { - onMetaFieldTypeValueDeleted(entityId, field.id, metaFieldValue.id).then( - () => removeValueFromFields() - ); + onMetaFieldTypeValueDeleted(entityId, field.id, metaFieldValue.id) + .then(() => removeValueFromFields()) + .catch(() => {}); } else { removeValueFromFields(); } diff --git a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js index 2868297b..9c6c7c19 100644 --- a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js @@ -119,7 +119,9 @@ const MuiTableSortableV2 = ({ const movedItemId = movedItem?.[idKey] ?? movedItem?.id; const reordered = arrayMove(data, oldIndex, newIndex).map((item, idx) => - updateOrderKey ? { ...item, [updateOrderKey]: idx + 1 } : item + updateOrderKey + ? { ...item, [updateOrderKey]: (currentPage - 1) * perPage + idx + 1 } + : item ); const newOrder = updateOrderKey From 8fad2058394528d78f907b13b2f5e4af32432663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 20 Jul 2026 18:04:52 -0300 Subject: [PATCH 08/14] fix: adjusting typo on showConfirmDialog prop (iconType) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../mui/formik-inputs/additional-input/meta-field-values-v2.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js index 50a5a07c..9da35a09 100644 --- a/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js +++ b/src/components/mui/formik-inputs/additional-input/meta-field-values-v2.js @@ -87,7 +87,7 @@ const MetaFieldValuesV2 = ({ const isConfirmed = await showConfirmDialog({ title: T.translate("general.are_you_sure"), text: T.translate("meta_fields.delete_value_warning"), - type: "warning", + iconType: "warning", confirmButtonColor: "#DD6B55", confirmButtonText: T.translate("general.yes_delete") }); From e49fbc2bea6ce22566626182acd94ab0d53ad91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 20 Jul 2026 18:21:23 -0300 Subject: [PATCH 09/14] fix: adjust test props to align with showConfirmDialog props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/__tests__/meta-field-values-v2.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/__tests__/meta-field-values-v2.test.js b/src/components/mui/__tests__/meta-field-values-v2.test.js index f2c1ad5a..c1c1f0e6 100644 --- a/src/components/mui/__tests__/meta-field-values-v2.test.js +++ b/src/components/mui/__tests__/meta-field-values-v2.test.js @@ -275,7 +275,7 @@ describe("MetaFieldValuesV2", () => { expect(showConfirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.any(String), - type: "warning" + iconType: "warning" }) ); }); From d8785dd173086a47413d752c5f419f5fba3a76fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 21 Jul 2026 16:14:37 -0300 Subject: [PATCH 10/14] fix: split files and test for v2 version, fix iconType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/index.js | 6 +- .../__tests__/additional-input-core.test.js | 358 ++++++++++++++++ .../additional-input-list-core.test.js | 400 ++++++++++++++++++ .../additional-input-list-v2.test.js | 38 ++ .../__tests__/additional-input-list.test.js | 399 +---------------- .../mui/__tests__/additional-input-v2.test.js | 38 ++ .../mui/__tests__/additional-input.test.js | 384 +---------------- .../additional-input/additional-input-core.js | 207 +++++++++ .../additional-input-list-core.js | 128 ++++++ .../additional-input-list-v2.js | 22 + .../additional-input/additional-input-list.js | 118 +----- .../additional-input/additional-input-v2.js | 22 + .../additional-input/additional-input.js | 196 +-------- .../mui-table-sortable-v2.js | 2 +- webpack.common.js | 2 + 15 files changed, 1263 insertions(+), 1057 deletions(-) create mode 100644 src/components/mui/__tests__/additional-input-core.test.js create mode 100644 src/components/mui/__tests__/additional-input-list-core.test.js create mode 100644 src/components/mui/__tests__/additional-input-list-v2.test.js create mode 100644 src/components/mui/__tests__/additional-input-v2.test.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-core.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-list-core.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js create mode 100644 src/components/mui/formik-inputs/additional-input/additional-input-v2.js diff --git a/src/components/index.js b/src/components/index.js index 2ac3013e..e99e9857 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -138,7 +138,9 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd // export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd // export {default as MuiStripePayment} from './mui/StripePayment' // @stripe/react-stripe-js, @stripe/stripe-js -// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop -// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) or @dnd-kit (via DragNDropList) depending on the `useV2` prop +// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) +// export {default as MuiAdditionalInputV2} from './mui/formik-inputs/additional-input/additional-input-v2' // @dnd-kit (via DragNDropList) +// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) +// export {default as MuiAdditionalInputListV2} from './mui/formik-inputs/additional-input/additional-input-list-v2' // @dnd-kit (via DragNDropList) // export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities // export {default as MuiTableSortableV2} from './mui/sortable-table-v2/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities diff --git a/src/components/mui/__tests__/additional-input-core.test.js b/src/components/mui/__tests__/additional-input-core.test.js new file mode 100644 index 00000000..a4a87939 --- /dev/null +++ b/src/components/mui/__tests__/additional-input-core.test.js @@ -0,0 +1,358 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik, Form, useFormikContext } from "formik"; +import "@testing-library/jest-dom"; +import AdditionalInputCore from "../formik-inputs/additional-input/additional-input-core"; + +const MockMetaFieldValuesComponent = () => ( +
MetaFieldValues
+); + +// Helper function to render the component with Formik +const renderWithFormik = (props, initialValues = { meta_fields: [] }) => + render( + +
+ + +
+ ); + +describe("AdditionalInputCore", () => { + const defaultItem = { + id: 1, + name: "Test Field", + type: "", + is_required: false, + minimum_quantity: 0, + maximum_quantity: 0, + values: [] + }; + + const defaultProps = { + item: defaultItem, + itemIdx: 0, + baseName: "meta_fields", + onAdd: jest.fn(), + onDelete: jest.fn(), + onDeleteValue: jest.fn(), + entityId: 1, + isAddDisabled: false, + MetaFieldValuesComponent: MockMetaFieldValuesComponent + }; + + const defaultInitialMetaFields = { + meta_fields: [defaultItem] + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + test("renders name, type and is_required fields", () => { + renderWithFormik(defaultProps, defaultInitialMetaFields); + + expect( + screen.getByPlaceholderText( + "additional_inputs.placeholders.title" + ) + ).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeInTheDocument(); + }); + + test("renders add and delete buttons", () => { + renderWithFormik(defaultProps, defaultInitialMetaFields); + + expect( + screen.getByRole("button", { name: /delete/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument(); + }); + }); + + describe("Conditional rendering based on type", () => { + test("shows the values component when type is CheckBoxList", () => { + const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; + + renderWithFormik( + { ...defaultProps, item: itemWithOptions }, + { meta_fields: [itemWithOptions] } + ); + + expect( + screen.getByTestId("meta-field-values-component") + ).toBeInTheDocument(); + }); + + test("shows the values component when type is ComboBox", () => { + const itemWithOptions = { ...defaultItem, type: "ComboBox" }; + + renderWithFormik( + { ...defaultProps, item: itemWithOptions }, + { meta_fields: [itemWithOptions] } + ); + + expect( + screen.getByTestId("meta-field-values-component") + ).toBeInTheDocument(); + }); + + test("shows the values component when type is RadioButtonList", () => { + const itemWithOptions = { ...defaultItem, type: "RadioButtonList" }; + + renderWithFormik( + { ...defaultProps, item: itemWithOptions }, + { meta_fields: [itemWithOptions] } + ); + + expect( + screen.getByTestId("meta-field-values-component") + ).toBeInTheDocument(); + }); + + test("does not show the values component when type is Text", () => { + renderWithFormik(defaultProps, defaultInitialMetaFields); + + expect( + screen.queryByTestId("meta-field-values-component") + ).not.toBeInTheDocument(); + }); + + test("shows quantity fields when type is Quantity", () => { + const itemQuantity = { ...defaultItem, type: "Quantity" }; + + renderWithFormik( + { ...defaultProps, item: itemQuantity }, + { meta_fields: [itemQuantity] } + ); + + expect( + screen.getByPlaceholderText( + "additional_inputs.placeholders.minimum_quantity" + ) + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText( + "additional_inputs.placeholders.maximum_quantity" + ) + ).toBeInTheDocument(); + }); + + test("does not show quantity fields when type is not Quantity", () => { + renderWithFormik(defaultProps, defaultInitialMetaFields); + + expect( + screen.queryByPlaceholderText( + "additional_inputs.placeholders.minimum_quantity" + ) + ).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText( + "additional_inputs.placeholders.maximum_quantity" + ) + ).not.toBeInTheDocument(); + }); + }); + + describe("Button interactions", () => { + test("calls onDelete with item and index when delete button is clicked", async () => { + const mockOnDelete = jest.fn(); + + renderWithFormik( + { ...defaultProps, onDelete: mockOnDelete }, + defaultInitialMetaFields + ); + + const deleteButton = screen.getByRole("button", { name: /delete/i }); + await userEvent.click(deleteButton); + + expect(mockOnDelete).toHaveBeenCalledWith(defaultItem, 0); + }); + + test("calls onAdd when add button is clicked", async () => { + const mockOnAdd = jest.fn(); + + renderWithFormik( + { ...defaultProps, onAdd: mockOnAdd }, + defaultInitialMetaFields + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + await userEvent.click(addButton); + + expect(mockOnAdd).toHaveBeenCalled(); + }); + + test("disables add button when isAddDisabled is true", () => { + renderWithFormik( + { ...defaultProps, isAddDisabled: true }, + defaultInitialMetaFields + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).toBeDisabled(); + }); + + test("enables add button when isAddDisabled is false", () => { + renderWithFormik( + { ...defaultProps, isAddDisabled: false }, + defaultInitialMetaFields + ); + + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).not.toBeDisabled(); + }); + }); + + describe("handleTypeChange", () => { + test("clears values when switching from an options-based type to a non-options type", async () => { + const itemWithValues = { + ...defaultItem, + type: "CheckBoxList", + values: [{ value: "opt1", name: "Option 1", is_default: false }] + }; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
+ {values.meta_fields[0].values.length} +
+ + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("values-count")).toHaveTextContent("1"); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "CheckBox" })); + + await waitFor(() => { + expect(screen.getByTestId("values-count")).toHaveTextContent("0"); + }); + }); + + test("preserves values when switching between options-based types", async () => { + const itemWithValues = { + ...defaultItem, + type: "CheckBoxList", + values: [{ value: "opt1", name: "Option 1", is_default: false }] + }; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
+ {values.meta_fields[0].values.length} +
+ + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("values-count")).toHaveTextContent("1"); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "ComboBox" })); + + await waitFor(() => { + expect(screen.getByTestId("values-count")).toHaveTextContent("1"); + }); + }); + }); + + describe("Error display", () => { + test("shows values error when touched and has error", () => { + const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; + + render( + +
+ + +
+ ); + + expect( + screen.getByText("At least one option required") + ).toBeInTheDocument(); + }); + + test("does not show values error when not touched", () => { + const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; + + render( + +
+ + +
+ ); + + expect( + screen.queryByText("At least one option required") + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/mui/__tests__/additional-input-list-core.test.js b/src/components/mui/__tests__/additional-input-list-core.test.js new file mode 100644 index 00000000..4bc12a01 --- /dev/null +++ b/src/components/mui/__tests__/additional-input-list-core.test.js @@ -0,0 +1,400 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik, Form, useFormikContext } from "formik"; +import "@testing-library/jest-dom"; +import AdditionalInputListCore from "../formik-inputs/additional-input/additional-input-list-core"; +import showConfirmDialog from "../showConfirmDialog"; + +// Mocks +jest.mock("../showConfirmDialog", () => jest.fn()); + +function MockAdditionalInputComponent({ + item, + itemIdx, + onAdd, + onDelete, + isAddDisabled +}) { + return ( +
+ {item.name} + {item.type} + + +
+ ); +} + +// Helper function to render the component with Formik +const renderWithFormik = (props, initialValues = { meta_fields: [] }) => + render( + +
+ + +
+ ); + +describe("AdditionalInputListCore", () => { + const defaultMetaField = { + id: 1, + name: "Field 1", + type: "Text", + is_required: false, + minimum_quantity: 0, + maximum_quantity: 0, + values: [] + }; + + const defaultProps = { + name: "meta_fields", + onDelete: jest.fn(), + onDeleteValue: jest.fn(), + entityId: 1, + AdditionalInputComponent: MockAdditionalInputComponent + }; + + const defaultInitialValues = { + meta_fields: [defaultMetaField] + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + test("renders an AdditionalInput for each meta field", () => { + const multipleFields = { + meta_fields: [ + { ...defaultMetaField, id: 1, name: "Field 1" }, + { ...defaultMetaField, id: 2, name: "Field 2" }, + { ...defaultMetaField, id: 3, name: "Field 3" } + ] + }; + + renderWithFormik(defaultProps, multipleFields); + + expect(screen.getByTestId("additional-input-0")).toBeInTheDocument(); + expect(screen.getByTestId("additional-input-1")).toBeInTheDocument(); + expect(screen.getByTestId("additional-input-2")).toBeInTheDocument(); + expect(screen.getByTestId("item-name-0")).toHaveTextContent("Field 1"); + expect(screen.getByTestId("item-name-1")).toHaveTextContent("Field 2"); + expect(screen.getByTestId("item-name-2")).toHaveTextContent("Field 3"); + }); + + test("renders a default metafield when meta_fields is empty", () => { + renderWithFormik(defaultProps, { meta_fields: [] }); + + // Should render one default empty field + expect(screen.getByTestId("additional-input-0")).toBeInTheDocument(); + expect(screen.getByTestId("item-name-0")).toHaveTextContent(""); + expect(screen.getByTestId("item-type-0")).toHaveTextContent(""); + }); + }); + + describe("handleAddItem", () => { + test("adds a new empty meta field when onAdd is called", async () => { + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
{values.meta_fields.length}
+ + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("field-count")).toHaveTextContent("1"); + + const addButton = screen.getByTestId("add-btn-0"); + await userEvent.click(addButton); + + await waitFor(() => { + expect(screen.getByTestId("field-count")).toHaveTextContent("2"); + }); + }); + + test("new meta field starts with empty values when an existing field already has values", async () => { + let capturedFields = null; + + const TestWrapper = () => { + const { values } = useFormikContext(); + capturedFields = values.meta_fields; + return ; + }; + + render( + +
+ + +
+ ); + + const addButton = screen.getByTestId("add-btn-0"); + await userEvent.click(addButton); + + await waitFor(() => { + expect(capturedFields).toHaveLength(2); + expect(capturedFields[1].values).toEqual([]); + }); + }); + }); + + describe("handleRemove", () => { + test("shows confirmation dialog when delete is clicked", async () => { + showConfirmDialog.mockResolvedValue(false); + + renderWithFormik(defaultProps, defaultInitialValues); + + const deleteButton = screen.getByTestId("delete-btn-0"); + await userEvent.click(deleteButton); + + expect(showConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.any(String), + type: "warning" + }) + ); + }); + + test("calls API and removes from UI when item has id", async () => { + const mockOnDelete = jest.fn().mockResolvedValue(); + showConfirmDialog.mockResolvedValue(true); + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + return ( + <> + +
{values.meta_fields.length}
+ + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("field-count")).toHaveTextContent("1"); + + const deleteButton = screen.getByTestId("delete-btn-0"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith(1, 1); // entityId, item.id + }); + }); + + test("removes from UI without API call when item has no id", async () => { + const mockOnDelete = jest.fn(); + showConfirmDialog.mockResolvedValue(true); + + const fieldWithoutId = { + name: "New Field", + type: "Text", + is_required: false, + values: [] + }; + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + return ( + <> + +
{values.meta_fields.length}
+ + ); + }; + + render( + +
+ + +
+ ); + + expect(screen.getByTestId("field-count")).toHaveTextContent("2"); + + // remove the second field (without id) + const deleteButton = screen.getByTestId("delete-btn-1"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDelete).not.toHaveBeenCalled(); + expect(screen.getByTestId("field-count")).toHaveTextContent("1"); + }); + }); + + test("resets to default meta field when last item is deleted", async () => { + const mockOnDelete = jest.fn().mockResolvedValue(); + showConfirmDialog.mockResolvedValue(true); + + const TestWrapper = ({ onDelete }) => { + const { values } = useFormikContext(); + return ( + <> + +
{values.meta_fields.length}
+
+ {values.meta_fields[0]?.name || "empty"} +
+ + ); + }; + + render( + +
+ + +
+ ); + + const deleteButton = screen.getByTestId("delete-btn-0"); + await userEvent.click(deleteButton); + + await waitFor(() => { + // should still have 1 field (the default empty one) + expect(screen.getByTestId("field-count")).toHaveTextContent("1"); + // field should be reset to empty + expect(screen.getByTestId("first-field-name")).toHaveTextContent( + "empty" + ); + }); + }); + }); + + describe("areMetafieldsIncomplete", () => { + test("disables add button when name is empty", () => { + const fieldWithEmptyName = { ...defaultMetaField, name: "" }; + + renderWithFormik(defaultProps, { meta_fields: [fieldWithEmptyName] }); + + expect(screen.getByTestId("add-btn-0")).toBeDisabled(); + }); + + test("disables add button when type is empty", () => { + const fieldWithEmptyType = { + ...defaultMetaField, + name: "Field", + type: "" + }; + + renderWithFormik(defaultProps, { meta_fields: [fieldWithEmptyType] }); + + expect(screen.getByTestId("add-btn-0")).toBeDisabled(); + }); + + test("disables add button when type with options has no values", () => { + const fieldWithNoValues = { + ...defaultMetaField, + name: "Field", + type: "CheckBoxList", + values: [] + }; + + renderWithFormik(defaultProps, { meta_fields: [fieldWithNoValues] }); + + expect(screen.getByTestId("add-btn-0")).toBeDisabled(); + }); + + test("disables add button when values are incomplete", () => { + const fieldWithIncompleteValues = { + ...defaultMetaField, + name: "Field", + type: "ComboBox", + values: [{ name: "Option", value: "" }] // value is empty + }; + + renderWithFormik(defaultProps, { + meta_fields: [fieldWithIncompleteValues] + }); + + expect(screen.getByTestId("add-btn-0")).toBeDisabled(); + }); + + test("enables add button when all fields are complete", () => { + const completeField = { + ...defaultMetaField, + name: "Field", + type: "Text" + }; + + renderWithFormik(defaultProps, { meta_fields: [completeField] }); + + expect(screen.getByTestId("add-btn-0")).not.toBeDisabled(); + }); + + test("enables add button when type with options has complete values", () => { + const completeFieldWithValues = { + ...defaultMetaField, + name: "Field", + type: "CheckBoxList", + values: [{ name: "Option 1", value: "opt1" }] + }; + + renderWithFormik(defaultProps, { + meta_fields: [completeFieldWithValues] + }); + + expect(screen.getByTestId("add-btn-0")).not.toBeDisabled(); + }); + }); +}); diff --git a/src/components/mui/__tests__/additional-input-list-v2.test.js b/src/components/mui/__tests__/additional-input-list-v2.test.js new file mode 100644 index 00000000..f6b8e201 --- /dev/null +++ b/src/components/mui/__tests__/additional-input-list-v2.test.js @@ -0,0 +1,38 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import AdditionalInputListV2 from "../formik-inputs/additional-input/additional-input-list-v2"; +import AdditionalInputV2 from "../formik-inputs/additional-input/additional-input-v2"; + +jest.mock("../formik-inputs/additional-input/additional-input-list-core", () => + function MockAdditionalInputListCore({ AdditionalInputComponent }) { + return ( +
+ {AdditionalInputComponent.name} +
+ ); + } +); + +describe("AdditionalInputListV2 (@dnd-kit entry point)", () => { + test("wires the @dnd-kit based AdditionalInputV2 into the core list", () => { + render(); + + expect(screen.getByTestId("input-component-name")).toHaveTextContent( + AdditionalInputV2.name + ); + }); +}); diff --git a/src/components/mui/__tests__/additional-input-list.test.js b/src/components/mui/__tests__/additional-input-list.test.js index 0db6d195..94b6a3ff 100644 --- a/src/components/mui/__tests__/additional-input-list.test.js +++ b/src/components/mui/__tests__/additional-input-list.test.js @@ -12,392 +12,27 @@ * */ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Formik, Form, useFormikContext } from "formik"; +import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import AdditionalInputList from "../formik-inputs/additional-input/additional-input-list"; -import showConfirmDialog from "../showConfirmDialog"; - -// Mocks -jest.mock("../showConfirmDialog", () => jest.fn()); - -jest.mock( - "../formik-inputs/additional-input/additional-input", - () => - function MockAdditionalInput({ - item, - itemIdx, - onAdd, - onDelete, - isAddDisabled - }) { - return ( -
- {item.name} - {item.type} - - -
- ); - } +import AdditionalInput from "../formik-inputs/additional-input/additional-input"; + +jest.mock("../formik-inputs/additional-input/additional-input-list-core", () => + function MockAdditionalInputListCore({ AdditionalInputComponent }) { + return ( +
+ {AdditionalInputComponent.name} +
+ ); + } ); -// Helper function to render the component with Formik -const renderWithFormik = (props, initialValues = { meta_fields: [] }) => - render( - -
- - -
- ); - -describe("AdditionalInputList", () => { - const defaultMetaField = { - id: 1, - name: "Field 1", - type: "Text", - is_required: false, - minimum_quantity: 0, - maximum_quantity: 0, - values: [] - }; - - const defaultProps = { - name: "meta_fields", - onDelete: jest.fn(), - onDeleteValue: jest.fn(), - entityId: 1 - }; - - const defaultInitialValues = { - meta_fields: [defaultMetaField] - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Rendering", () => { - test("renders an AdditionalInput for each meta field", () => { - const multipleFields = { - meta_fields: [ - { ...defaultMetaField, id: 1, name: "Field 1" }, - { ...defaultMetaField, id: 2, name: "Field 2" }, - { ...defaultMetaField, id: 3, name: "Field 3" } - ] - }; - - renderWithFormik(defaultProps, multipleFields); - - expect(screen.getByTestId("additional-input-0")).toBeInTheDocument(); - expect(screen.getByTestId("additional-input-1")).toBeInTheDocument(); - expect(screen.getByTestId("additional-input-2")).toBeInTheDocument(); - expect(screen.getByTestId("item-name-0")).toHaveTextContent("Field 1"); - expect(screen.getByTestId("item-name-1")).toHaveTextContent("Field 2"); - expect(screen.getByTestId("item-name-2")).toHaveTextContent("Field 3"); - }); - - test("renders a default metafield when meta_fields is empty", () => { - renderWithFormik(defaultProps, { meta_fields: [] }); - - // Should render one default empty field - expect(screen.getByTestId("additional-input-0")).toBeInTheDocument(); - expect(screen.getByTestId("item-name-0")).toHaveTextContent(""); - expect(screen.getByTestId("item-type-0")).toHaveTextContent(""); - }); - }); - - describe("handleAddItem", () => { - test("adds a new empty meta field when onAdd is called", async () => { - const TestWrapper = () => { - const { values } = useFormikContext(); - return ( - <> - -
{values.meta_fields.length}
- - ); - }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("field-count")).toHaveTextContent("1"); - - const addButton = screen.getByTestId("add-btn-0"); - await userEvent.click(addButton); - - await waitFor(() => { - expect(screen.getByTestId("field-count")).toHaveTextContent("2"); - }); - }); - - test("new meta field starts with empty values when an existing field already has values", async () => { - let capturedFields = null; - - const TestWrapper = () => { - const { values } = useFormikContext(); - capturedFields = values.meta_fields; - return ; - }; - - render( - -
- - -
- ); - - const addButton = screen.getByTestId("add-btn-0"); - await userEvent.click(addButton); - - await waitFor(() => { - expect(capturedFields).toHaveLength(2); - expect(capturedFields[1].values).toEqual([]); - }); - }); - }); - - describe("handleRemove", () => { - test("shows confirmation dialog when delete is clicked", async () => { - showConfirmDialog.mockResolvedValue(false); - - renderWithFormik(defaultProps, defaultInitialValues); - - const deleteButton = screen.getByTestId("delete-btn-0"); - await userEvent.click(deleteButton); - - expect(showConfirmDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.any(String), - type: "warning" - }) - ); - }); - - test("calls API and removes from UI when item has id", async () => { - const mockOnDelete = jest.fn().mockResolvedValue(); - showConfirmDialog.mockResolvedValue(true); - - const TestWrapper = ({ onDelete }) => { - const { values } = useFormikContext(); - return ( - <> - -
{values.meta_fields.length}
- - ); - }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("field-count")).toHaveTextContent("1"); - - const deleteButton = screen.getByTestId("delete-btn-0"); - await userEvent.click(deleteButton); - - await waitFor(() => { - expect(mockOnDelete).toHaveBeenCalledWith(1, 1); // entityId, item.id - }); - }); - - test("removes from UI without API call when item has no id", async () => { - const mockOnDelete = jest.fn(); - showConfirmDialog.mockResolvedValue(true); - - const fieldWithoutId = { - name: "New Field", - type: "Text", - is_required: false, - values: [] - }; - - const TestWrapper = ({ onDelete }) => { - const { values } = useFormikContext(); - return ( - <> - -
{values.meta_fields.length}
- - ); - }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("field-count")).toHaveTextContent("2"); - - // remove the second field (without id) - const deleteButton = screen.getByTestId("delete-btn-1"); - await userEvent.click(deleteButton); - - await waitFor(() => { - expect(mockOnDelete).not.toHaveBeenCalled(); - expect(screen.getByTestId("field-count")).toHaveTextContent("1"); - }); - }); - - test("resets to default meta field when last item is deleted", async () => { - const mockOnDelete = jest.fn().mockResolvedValue(); - showConfirmDialog.mockResolvedValue(true); - - const TestWrapper = ({ onDelete }) => { - const { values } = useFormikContext(); - return ( - <> - -
{values.meta_fields.length}
-
- {values.meta_fields[0]?.name || "empty"} -
- - ); - }; - - render( - -
- - -
- ); - - const deleteButton = screen.getByTestId("delete-btn-0"); - await userEvent.click(deleteButton); - - await waitFor(() => { - // should still have 1 field (the default empty one) - expect(screen.getByTestId("field-count")).toHaveTextContent("1"); - // field should be reset to empty - expect(screen.getByTestId("first-field-name")).toHaveTextContent( - "empty" - ); - }); - }); - }); - - describe("areMetafieldsIncomplete", () => { - test("disables add button when name is empty", () => { - const fieldWithEmptyName = { ...defaultMetaField, name: "" }; - - renderWithFormik(defaultProps, { meta_fields: [fieldWithEmptyName] }); - - expect(screen.getByTestId("add-btn-0")).toBeDisabled(); - }); - - test("disables add button when type is empty", () => { - const fieldWithEmptyType = { - ...defaultMetaField, - name: "Field", - type: "" - }; - - renderWithFormik(defaultProps, { meta_fields: [fieldWithEmptyType] }); - - expect(screen.getByTestId("add-btn-0")).toBeDisabled(); - }); - - test("disables add button when type with options has no values", () => { - const fieldWithNoValues = { - ...defaultMetaField, - name: "Field", - type: "CheckBoxList", - values: [] - }; - - renderWithFormik(defaultProps, { meta_fields: [fieldWithNoValues] }); - - expect(screen.getByTestId("add-btn-0")).toBeDisabled(); - }); - - test("disables add button when values are incomplete", () => { - const fieldWithIncompleteValues = { - ...defaultMetaField, - name: "Field", - type: "ComboBox", - values: [{ name: "Option", value: "" }] // value is empty - }; - - renderWithFormik(defaultProps, { - meta_fields: [fieldWithIncompleteValues] - }); - - expect(screen.getByTestId("add-btn-0")).toBeDisabled(); - }); - - test("enables add button when all fields are complete", () => { - const completeField = { - ...defaultMetaField, - name: "Field", - type: "Text" - }; - - renderWithFormik(defaultProps, { meta_fields: [completeField] }); - - expect(screen.getByTestId("add-btn-0")).not.toBeDisabled(); - }); - - test("enables add button when type with options has complete values", () => { - const completeFieldWithValues = { - ...defaultMetaField, - name: "Field", - type: "CheckBoxList", - values: [{ name: "Option 1", value: "opt1" }] - }; - - renderWithFormik(defaultProps, { - meta_fields: [completeFieldWithValues] - }); +describe("AdditionalInputList (v1 entry point)", () => { + test("wires the react-beautiful-dnd based AdditionalInput into the core list", () => { + render(); - expect(screen.getByTestId("add-btn-0")).not.toBeDisabled(); - }); + expect(screen.getByTestId("input-component-name")).toHaveTextContent( + AdditionalInput.name + ); }); }); diff --git a/src/components/mui/__tests__/additional-input-v2.test.js b/src/components/mui/__tests__/additional-input-v2.test.js new file mode 100644 index 00000000..f08c6291 --- /dev/null +++ b/src/components/mui/__tests__/additional-input-v2.test.js @@ -0,0 +1,38 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import AdditionalInputV2 from "../formik-inputs/additional-input/additional-input-v2"; +import MetaFieldValuesV2 from "../formik-inputs/additional-input/meta-field-values-v2"; + +jest.mock("../formik-inputs/additional-input/additional-input-core", () => + function MockAdditionalInputCore({ MetaFieldValuesComponent }) { + return ( +
+ {MetaFieldValuesComponent.name} +
+ ); + } +); + +describe("AdditionalInputV2 (@dnd-kit entry point)", () => { + test("wires the @dnd-kit based MetaFieldValuesV2 into the core component", () => { + render(); + + expect(screen.getByTestId("values-component-name")).toHaveTextContent( + MetaFieldValuesV2.name + ); + }); +}); diff --git a/src/components/mui/__tests__/additional-input.test.js b/src/components/mui/__tests__/additional-input.test.js index 6e8bbfed..9c41de7f 100644 --- a/src/components/mui/__tests__/additional-input.test.js +++ b/src/components/mui/__tests__/additional-input.test.js @@ -12,377 +12,27 @@ * */ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Formik, Form, useFormikContext } from "formik"; +import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import AdditionalInput from "../formik-inputs/additional-input/additional-input"; - -// Mocks -jest.mock( - "../formik-inputs/additional-input/meta-field-values", - () => - function MockMetaFieldValues() { - return
MetaFieldValues
; - } -); - -jest.mock( - "../formik-inputs/additional-input/meta-field-values-v2", - () => - function MockMetaFieldValuesV2() { - return
MetaFieldValuesV2
; - } +import MetaFieldValues from "../formik-inputs/additional-input/meta-field-values"; + +jest.mock("../formik-inputs/additional-input/additional-input-core", () => + function MockAdditionalInputCore({ MetaFieldValuesComponent }) { + return ( +
+ {MetaFieldValuesComponent.name} +
+ ); + } ); -// Helper function to render the component with Formik -const renderWithFormik = (props, initialValues = { meta_fields: [] }) => - render( - -
- - -
- ); - -describe("AdditionalInput", () => { - const defaultItem = { - id: 1, - name: "Test Field", - type: "", - is_required: false, - minimum_quantity: 0, - maximum_quantity: 0, - values: [] - }; - - const defaultProps = { - item: defaultItem, - itemIdx: 0, - baseName: "meta_fields", - onAdd: jest.fn(), - onDelete: jest.fn(), - onDeleteValue: jest.fn(), - entityId: 1, - isAddDisabled: false - }; - - const defaultInitialMetaFields = { - meta_fields: [defaultItem] - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Rendering", () => { - test("renders name, type and is_required fields", () => { - renderWithFormik(defaultProps, defaultInitialMetaFields); - - expect( - screen.getByPlaceholderText( - "additional_inputs.placeholders.title" - ) - ).toBeInTheDocument(); - expect(screen.getByRole("combobox")).toBeInTheDocument(); - expect(screen.getByRole("checkbox")).toBeInTheDocument(); - }); - - test("renders add and delete buttons", () => { - renderWithFormik(defaultProps, defaultInitialMetaFields); - - expect( - screen.getByRole("button", { name: /delete/i }) - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument(); - }); - }); - - describe("Conditional rendering based on type", () => { - test("shows MetaFieldValues when type is CheckBoxList", () => { - const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; - - renderWithFormik( - { ...defaultProps, item: itemWithOptions }, - { meta_fields: [itemWithOptions] } - ); - - expect(screen.getByTestId("meta-field-values")).toBeInTheDocument(); - }); - - test("shows MetaFieldValues when type is ComboBox", () => { - const itemWithOptions = { ...defaultItem, type: "ComboBox" }; - - renderWithFormik( - { ...defaultProps, item: itemWithOptions }, - { meta_fields: [itemWithOptions] } - ); - - expect(screen.getByTestId("meta-field-values")).toBeInTheDocument(); - }); - - test("shows MetaFieldValues when type is RadioButtonList", () => { - const itemWithOptions = { ...defaultItem, type: "RadioButtonList" }; - - renderWithFormik( - { ...defaultProps, item: itemWithOptions }, - { meta_fields: [itemWithOptions] } - ); - - expect(screen.getByTestId("meta-field-values")).toBeInTheDocument(); - }); - - test("does not show MetaFieldValues when type is Text", () => { - renderWithFormik(defaultProps, defaultInitialMetaFields); - - expect(screen.queryByTestId("meta-field-values")).not.toBeInTheDocument(); - }); - - test("shows MetaFieldValues (v1) instead of MetaFieldValuesV2 by default", () => { - const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; - - renderWithFormik( - { ...defaultProps, item: itemWithOptions }, - { meta_fields: [itemWithOptions] } - ); - - expect(screen.getByTestId("meta-field-values")).toBeInTheDocument(); - expect( - screen.queryByTestId("meta-field-values-v2") - ).not.toBeInTheDocument(); - }); - - test("shows MetaFieldValuesV2 instead of MetaFieldValues when useV2 is true", () => { - const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; - - renderWithFormik( - { ...defaultProps, item: itemWithOptions, useV2: true }, - { meta_fields: [itemWithOptions] } - ); - - expect(screen.getByTestId("meta-field-values-v2")).toBeInTheDocument(); - expect(screen.queryByTestId("meta-field-values")).not.toBeInTheDocument(); - }); - - test("shows quantity fields when type is Quantity", () => { - const itemQuantity = { ...defaultItem, type: "Quantity" }; - - renderWithFormik( - { ...defaultProps, item: itemQuantity }, - { meta_fields: [itemQuantity] } - ); - - expect( - screen.getByPlaceholderText( - "additional_inputs.placeholders.minimum_quantity" - ) - ).toBeInTheDocument(); - expect( - screen.getByPlaceholderText( - "additional_inputs.placeholders.maximum_quantity" - ) - ).toBeInTheDocument(); - }); - - test("does not show quantity fields when type is not Quantity", () => { - renderWithFormik(defaultProps, defaultInitialMetaFields); - - expect( - screen.queryByPlaceholderText( - "additional_inputs.placeholders.minimum_quantity" - ) - ).not.toBeInTheDocument(); - expect( - screen.queryByPlaceholderText( - "additional_inputs.placeholders.maximum_quantity" - ) - ).not.toBeInTheDocument(); - }); - }); - - describe("Button interactions", () => { - test("calls onDelete with item and index when delete button is clicked", async () => { - const mockOnDelete = jest.fn(); - - renderWithFormik( - { ...defaultProps, onDelete: mockOnDelete }, - defaultInitialMetaFields - ); - - const deleteButton = screen.getByRole("button", { name: /delete/i }); - await userEvent.click(deleteButton); - - expect(mockOnDelete).toHaveBeenCalledWith(defaultItem, 0); - }); - - test("calls onAdd when add button is clicked", async () => { - const mockOnAdd = jest.fn(); - - renderWithFormik( - { ...defaultProps, onAdd: mockOnAdd }, - defaultInitialMetaFields - ); - - const addButton = screen.getByRole("button", { name: /add/i }); - await userEvent.click(addButton); - - expect(mockOnAdd).toHaveBeenCalled(); - }); - - test("disables add button when isAddDisabled is true", () => { - renderWithFormik( - { ...defaultProps, isAddDisabled: true }, - defaultInitialMetaFields - ); - - const addButton = screen.getByRole("button", { name: /add/i }); - expect(addButton).toBeDisabled(); - }); - - test("enables add button when isAddDisabled is false", () => { - renderWithFormik( - { ...defaultProps, isAddDisabled: false }, - defaultInitialMetaFields - ); - - const addButton = screen.getByRole("button", { name: /add/i }); - expect(addButton).not.toBeDisabled(); - }); - }); - - describe("handleTypeChange", () => { - test("clears values when switching from an options-based type to a non-options type", async () => { - const itemWithValues = { - ...defaultItem, - type: "CheckBoxList", - values: [{ value: "opt1", name: "Option 1", is_default: false }] - }; - - const TestWrapper = () => { - const { values } = useFormikContext(); - return ( - <> - -
- {values.meta_fields[0].values.length} -
- - ); - }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("values-count")).toHaveTextContent("1"); - - await userEvent.click(screen.getByRole("combobox")); - await userEvent.click(await screen.findByRole("option", { name: "CheckBox" })); - - await waitFor(() => { - expect(screen.getByTestId("values-count")).toHaveTextContent("0"); - }); - }); - - test("preserves values when switching between options-based types", async () => { - const itemWithValues = { - ...defaultItem, - type: "CheckBoxList", - values: [{ value: "opt1", name: "Option 1", is_default: false }] - }; - - const TestWrapper = () => { - const { values } = useFormikContext(); - return ( - <> - -
- {values.meta_fields[0].values.length} -
- - ); - }; - - render( - -
- - -
- ); - - expect(screen.getByTestId("values-count")).toHaveTextContent("1"); - - await userEvent.click(screen.getByRole("combobox")); - await userEvent.click(await screen.findByRole("option", { name: "ComboBox" })); - - await waitFor(() => { - expect(screen.getByTestId("values-count")).toHaveTextContent("1"); - }); - }); - }); - - describe("Error display", () => { - test("shows values error when touched and has error", () => { - const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; - - render( - -
- - -
- ); - - expect( - screen.getByText("At least one option required") - ).toBeInTheDocument(); - }); - - test("does not show values error when not touched", () => { - const itemWithOptions = { ...defaultItem, type: "CheckBoxList" }; - - render( - -
- - -
- ); +describe("AdditionalInput (v1 entry point)", () => { + test("wires the react-beautiful-dnd based MetaFieldValues into the core component", () => { + render(); - expect( - screen.queryByText("At least one option required") - ).not.toBeInTheDocument(); - }); + expect(screen.getByTestId("values-component-name")).toHaveTextContent( + MetaFieldValues.name + ); }); }); diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-core.js b/src/components/mui/formik-inputs/additional-input/additional-input-core.js new file mode 100644 index 00000000..b798f320 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-core.js @@ -0,0 +1,207 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { + Box, + Button, + Divider, + FormHelperText, + Grid2, + InputLabel, + MenuItem +} from "@mui/material"; +import { useFormikContext, getIn } from "formik"; +import T from "i18n-react/dist/i18n-react"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import MuiFormikTextField from "../mui-formik-textfield"; +import MuiFormikSelect from "../mui-formik-select"; +import MuiFormikCheckbox from "../mui-formik-checkbox"; +import { + METAFIELD_TYPES, + METAFIELD_TYPES_WITH_OPTIONS +} from "../../../../utils/constants"; + +const AdditionalInputCore = ({ + item, + itemIdx, + baseName, + onAdd, + onDelete, + onDeleteValue, + entityId, + isAddDisabled, + MetaFieldValuesComponent +}) => { + const { errors, touched, values, setFieldValue } = useFormikContext(); + + const buildFieldName = (fieldName) => `${baseName}[${itemIdx}].${fieldName}`; + const currentType = getIn(values, buildFieldName("type")); + + const handleTypeChange = (e) => { + const newType = e.target.value; + setFieldValue(buildFieldName("type"), newType); + if (!METAFIELD_TYPES_WITH_OPTIONS.includes(newType)) { + setFieldValue(buildFieldName("values"), []); + } + }; + + const fieldErrors = getIn(errors, `${baseName}[${itemIdx}]`); + const fieldTouched = getIn(touched, `${baseName}[${itemIdx}]`); + + const showValuesError = + fieldTouched?.values && + fieldErrors?.values && + typeof fieldErrors.values === "string"; + + return ( + + + + + + + {T.translate("additional_inputs.title")} + + + + + + {T.translate("additional_inputs.type")} + + + {METAFIELD_TYPES.map((fieldType) => ( + + {fieldType} + + ))} + + + + + + + {METAFIELD_TYPES_WITH_OPTIONS.includes(currentType) && ( + <> + + + {showValuesError && ( + + {fieldErrors.values} + + )} + + )} + {currentType === "Quantity" && ( + + + + + + + + + )} + + + + + + + + + + ); +}; + +export default AdditionalInputCore; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js b/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js new file mode 100644 index 00000000..c91b56b9 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js @@ -0,0 +1,128 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect } from "react"; +import { useFormikContext, getIn } from "formik"; +import T from "i18n-react"; +import showConfirmDialog from "../../showConfirmDialog"; +import { METAFIELD_TYPES_WITH_OPTIONS } from "../../../../utils/constants"; + +const DEFAULT_META_FIELD = { + name: "", + type: "", + is_required: false, + minimum_quantity: 0, + maximum_quantity: 0, + values: [] +}; + +const AdditionalInputListCore = ({ + name, + onDelete, + onDeleteValue, + entityId, + AdditionalInputComponent +}) => { + const { values, setFieldValue, errors, setFieldTouched } = useFormikContext(); + + const metaFields = values[name] || []; + + useEffect(() => { + if (metaFields.length === 0) { + setFieldValue(name, [ + { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } + ]); + } + }, [metaFields.length]); + + const handleAddItem = () => { + setFieldValue(name, [ + ...metaFields, + { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } + ]); + }; + + const handleRemove = async (item, index) => { + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate("additional_inputs.delete_warning")} ${ + item.name + }`, + type: "warning", + confirmButtonColor: "#DD6B55", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (!isConfirmed) return; + + const removeFromUI = () => { + const newValues = metaFields.filter((_, idx) => idx !== index); + if (newValues.length === 0) { + newValues.push({ ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` }); + } + setFieldValue(name, newValues); + setFieldTouched(name, [], false); + }; + + if (item.id && onDelete) { + onDelete(entityId, item.id) + .then(() => removeFromUI()) + .catch((err) => console.error("Error deleting field from API", err)); + } else { + removeFromUI(); + } + }; + + const areMetafieldsIncomplete = () => { + const fieldErrors = getIn(errors, name); + if (fieldErrors && Array.isArray(fieldErrors)) { + const hasRealErrors = fieldErrors.some( + (err) => err && Object.keys(err).length > 0 + ); + if (hasRealErrors) return true; + } + + return metaFields.some((field) => { + if (!field.name?.trim() || !field.type) return true; + if (METAFIELD_TYPES_WITH_OPTIONS.includes(field.type)) { + if (!field.values || field.values.length === 0) return true; + const hasIncompleteValues = field.values.some( + (v) => !v.name?.trim() || !v.value?.trim() + ); + if (hasIncompleteValues) return true; + } + + return false; + }); + }; + + return ( + <> + {metaFields.map((item, itemIdx) => ( + + ))} + + ); +}; + +export default AdditionalInputListCore; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js new file mode 100644 index 00000000..b65525c5 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js @@ -0,0 +1,22 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import AdditionalInputListCore from "./additional-input-list-core"; +import AdditionalInputV2 from "./additional-input-v2"; + +const AdditionalInputListV2 = (props) => ( + +); + +export default AdditionalInputListV2; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list.js b/src/components/mui/formik-inputs/additional-input/additional-input-list.js index 15314d1e..80507b79 100644 --- a/src/components/mui/formik-inputs/additional-input/additional-input-list.js +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list.js @@ -11,120 +11,12 @@ * limitations under the License. * */ -import React, { useEffect } from "react"; -import { useFormikContext, getIn } from "formik"; -import T from "i18n-react"; +import React from "react"; +import AdditionalInputListCore from "./additional-input-list-core"; import AdditionalInput from "./additional-input"; -import showConfirmDialog from "../../showConfirmDialog"; -import { METAFIELD_TYPES_WITH_OPTIONS } from "../../../../utils/constants"; -const DEFAULT_META_FIELD = { - name: "", - type: "", - is_required: false, - minimum_quantity: 0, - maximum_quantity: 0, - values: [] -}; - -const AdditionalInputList = ({ - name, - onDelete, - onDeleteValue, - entityId, - useV2 = false -}) => { - const { values, setFieldValue, errors, setFieldTouched } = useFormikContext(); - - const metaFields = values[name] || []; - - useEffect(() => { - if (metaFields.length === 0) { - setFieldValue(name, [ - { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } - ]); - } - }, [metaFields.length]); - - const handleAddItem = () => { - setFieldValue(name, [ - ...metaFields, - { ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` } - ]); - }; - - const handleRemove = async (item, index) => { - const isConfirmed = await showConfirmDialog({ - title: T.translate("general.are_you_sure"), - text: `${T.translate("additional_inputs.delete_warning")} ${ - item.name - }`, - type: "warning", - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }); - - if (!isConfirmed) return; - - const removeFromUI = () => { - const newValues = metaFields.filter((_, idx) => idx !== index); - if (newValues.length === 0) { - newValues.push({ ...DEFAULT_META_FIELD, _key: `draft_${Date.now()}` }); - } - setFieldValue(name, newValues); - setFieldTouched(name, [], false); - }; - - if (item.id && onDelete) { - onDelete(entityId, item.id) - .then(() => removeFromUI()) - .catch((err) => console.error("Error deleting field from API", err)); - } else { - removeFromUI(); - } - }; - - const areMetafieldsIncomplete = () => { - const fieldErrors = getIn(errors, name); - if (fieldErrors && Array.isArray(fieldErrors)) { - const hasRealErrors = fieldErrors.some( - (err) => err && Object.keys(err).length > 0 - ); - if (hasRealErrors) return true; - } - - return metaFields.some((field) => { - if (!field.name?.trim() || !field.type) return true; - if (METAFIELD_TYPES_WITH_OPTIONS.includes(field.type)) { - if (!field.values || field.values.length === 0) return true; - const hasIncompleteValues = field.values.some( - (v) => !v.name?.trim() || !v.value?.trim() - ); - if (hasIncompleteValues) return true; - } - - return false; - }); - }; - - return ( - <> - {metaFields.map((item, itemIdx) => ( - - ))} - - ); -}; +const AdditionalInputList = (props) => ( + +); export default AdditionalInputList; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-v2.js b/src/components/mui/formik-inputs/additional-input/additional-input-v2.js new file mode 100644 index 00000000..68e6dce2 --- /dev/null +++ b/src/components/mui/formik-inputs/additional-input/additional-input-v2.js @@ -0,0 +1,22 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import AdditionalInputCore from "./additional-input-core"; +import MetaFieldValuesV2 from "./meta-field-values-v2"; + +const AdditionalInputV2 = (props) => ( + +); + +export default AdditionalInputV2; diff --git a/src/components/mui/formik-inputs/additional-input/additional-input.js b/src/components/mui/formik-inputs/additional-input/additional-input.js index 6021923c..94e717e6 100644 --- a/src/components/mui/formik-inputs/additional-input/additional-input.js +++ b/src/components/mui/formik-inputs/additional-input/additional-input.js @@ -12,199 +12,11 @@ * */ import React from "react"; -import { - Box, - Button, - Divider, - FormHelperText, - Grid2, - InputLabel, - MenuItem -} from "@mui/material"; -import { useFormikContext, getIn } from "formik"; -import T from "i18n-react/dist/i18n-react"; -import DeleteIcon from "@mui/icons-material/Delete"; -import AddIcon from "@mui/icons-material/Add"; +import AdditionalInputCore from "./additional-input-core"; import MetaFieldValues from "./meta-field-values"; -import MetaFieldValuesV2 from "./meta-field-values-v2"; -import MuiFormikTextField from "../mui-formik-textfield"; -import MuiFormikSelect from "../mui-formik-select"; -import MuiFormikCheckbox from "../mui-formik-checkbox"; -import { - METAFIELD_TYPES, - METAFIELD_TYPES_WITH_OPTIONS -} from "../../../../utils/constants"; -const AdditionalInput = ({ - item, - itemIdx, - baseName, - onAdd, - onDelete, - onDeleteValue, - entityId, - isAddDisabled, - useV2 = false -}) => { - const MetaFieldValuesComponent = useV2 ? MetaFieldValuesV2 : MetaFieldValues; - const { errors, touched, values, setFieldValue } = useFormikContext(); - - const buildFieldName = (fieldName) => `${baseName}[${itemIdx}].${fieldName}`; - const currentType = getIn(values, buildFieldName("type")); - - const handleTypeChange = (e) => { - const newType = e.target.value; - setFieldValue(buildFieldName("type"), newType); - if (!METAFIELD_TYPES_WITH_OPTIONS.includes(newType)) { - setFieldValue(buildFieldName("values"), []); - } - }; - - const fieldErrors = getIn(errors, `${baseName}[${itemIdx}]`); - const fieldTouched = getIn(touched, `${baseName}[${itemIdx}]`); - - const showValuesError = - fieldTouched?.values && - fieldErrors?.values && - typeof fieldErrors.values === "string"; - - return ( - - - - - - - {T.translate("additional_inputs.title")} - - - - - - {T.translate("additional_inputs.type")} - - - {METAFIELD_TYPES.map((fieldType) => ( - - {fieldType} - - ))} - - - - - - - {METAFIELD_TYPES_WITH_OPTIONS.includes(currentType) && ( - <> - - - {showValuesError && ( - - {fieldErrors.values} - - )} - - )} - {currentType === "Quantity" && ( - - - - - - - - - )} - - - - - - - - - - ); -}; +const AdditionalInput = (props) => ( + +); export default AdditionalInput; diff --git a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js index 9c6c7c19..e3b2d6b2 100644 --- a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js @@ -141,7 +141,7 @@ const MuiTableSortableV2 = ({ ? deleteDialogBody(getName(item)) : deleteDialogBody || `${T.translate("general.row_remove_warning")} ${getName(item)}`, - type: "warning", + iconType: "warning", showCancelButton: true, confirmButtonColor: "#DD6B55", confirmButtonText: T.translate("general.yes_delete") diff --git a/webpack.common.js b/webpack.common.js index f78669b5..db2f32b9 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -111,7 +111,9 @@ module.exports = { 'components/mui/table/custom-table-pagination': './src/components/mui/table/CustomTablePagination.js', 'components/mui/table/extra-rows': './src/components/mui/table/extra-rows/index.js', 'components/mui/formik-inputs/additional-input': './src/components/mui/formik-inputs/additional-input/additional-input.js', + 'components/mui/formik-inputs/additional-input-v2': './src/components/mui/formik-inputs/additional-input/additional-input-v2.js', 'components/mui/formik-inputs/additional-input-list': './src/components/mui/formik-inputs/additional-input/additional-input-list.js', + 'components/mui/formik-inputs/additional-input-list-v2': './src/components/mui/formik-inputs/additional-input/additional-input-list-v2.js', 'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js', 'components/mui/formik-inputs/checkbox-group': './src/components/mui/formik-inputs/mui-formik-checkbox-group.js', 'components/mui/formik-inputs/checkbox': './src/components/mui/formik-inputs/mui-formik-checkbox.js', From 64927c2a7a885297072a8f51edae73a6eda459f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 21 Jul 2026 17:09:17 -0300 Subject: [PATCH 11/14] fix: replace type with iconType on showConfirmDialog use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/__tests__/additional-input-list-core.test.js | 2 +- .../additional-input/additional-input-list-core.js | 2 +- .../mui/formik-inputs/additional-input/meta-field-values.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/mui/__tests__/additional-input-list-core.test.js b/src/components/mui/__tests__/additional-input-list-core.test.js index 4bc12a01..dc9433f9 100644 --- a/src/components/mui/__tests__/additional-input-list-core.test.js +++ b/src/components/mui/__tests__/additional-input-list-core.test.js @@ -201,7 +201,7 @@ describe("AdditionalInputListCore", () => { expect(showConfirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.any(String), - type: "warning" + iconType: "warning" }) ); }); diff --git a/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js b/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js index c91b56b9..5374ee72 100644 --- a/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js +++ b/src/components/mui/formik-inputs/additional-input/additional-input-list-core.js @@ -58,7 +58,7 @@ const AdditionalInputListCore = ({ text: `${T.translate("additional_inputs.delete_warning")} ${ item.name }`, - type: "warning", + iconType: "warning", confirmButtonColor: "#DD6B55", confirmButtonText: T.translate("general.yes_delete") }); diff --git a/src/components/mui/formik-inputs/additional-input/meta-field-values.js b/src/components/mui/formik-inputs/additional-input/meta-field-values.js index 67fb685a..d00f88de 100644 --- a/src/components/mui/formik-inputs/additional-input/meta-field-values.js +++ b/src/components/mui/formik-inputs/additional-input/meta-field-values.js @@ -74,7 +74,7 @@ const MetaFieldValues = ({ const isConfirmed = await showConfirmDialog({ title: T.translate("general.are_you_sure"), text: T.translate("meta_fields.delete_value_warning"), - type: "warning", + iconType: "warning", confirmButtonColor: "#DD6B55", confirmButtonText: T.translate("general.yes_delete") }); From 877b8a820e1d3e53e9bf0f0f5b94b7817e078282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 21 Jul 2026 17:33:41 -0300 Subject: [PATCH 12/14] fix: adjust typeIcon on meta field values test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/__tests__/meta-field-values.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/__tests__/meta-field-values.test.js b/src/components/mui/__tests__/meta-field-values.test.js index 4c0e20a5..cfaeaeb6 100644 --- a/src/components/mui/__tests__/meta-field-values.test.js +++ b/src/components/mui/__tests__/meta-field-values.test.js @@ -235,7 +235,7 @@ describe("MetaFieldValues", () => { expect(showConfirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.any(String), - type: "warning" + typeIcon: "warning" }) ); }); From 46d061ccbe0a630cbbc4ec21df07e93f335b10a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 22 Jul 2026 00:04:00 -0300 Subject: [PATCH 13/14] fix: fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/__tests__/meta-field-values.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/__tests__/meta-field-values.test.js b/src/components/mui/__tests__/meta-field-values.test.js index cfaeaeb6..34beccd5 100644 --- a/src/components/mui/__tests__/meta-field-values.test.js +++ b/src/components/mui/__tests__/meta-field-values.test.js @@ -235,7 +235,7 @@ describe("MetaFieldValues", () => { expect(showConfirmDialog).toHaveBeenCalledWith( expect.objectContaining({ title: expect.any(String), - typeIcon: "warning" + iconType: "warning" }) ); }); From 0617f9f88ef86eff5211efc3c42276d0726acc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 22 Jul 2026 18:16:11 -0300 Subject: [PATCH 14/14] fix: unify sensor/dragend under a new hook for both components, update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../hooks/__tests__/useDndKitReorder.test.js | 127 ++++++++++++++++++ .../DragNDropList/hooks/useDndKitReorder.js | 59 ++++++++ src/components/mui/DragNDropList/index.js | 49 ++----- .../__tests__/mui-table-sortable-v2.test.js | 30 +++++ .../mui-table-sortable-v2.js | 70 ++++------ 5 files changed, 250 insertions(+), 85 deletions(-) create mode 100644 src/components/mui/DragNDropList/hooks/__tests__/useDndKitReorder.test.js create mode 100644 src/components/mui/DragNDropList/hooks/useDndKitReorder.js diff --git a/src/components/mui/DragNDropList/hooks/__tests__/useDndKitReorder.test.js b/src/components/mui/DragNDropList/hooks/__tests__/useDndKitReorder.test.js new file mode 100644 index 00000000..b03adbae --- /dev/null +++ b/src/components/mui/DragNDropList/hooks/__tests__/useDndKitReorder.test.js @@ -0,0 +1,127 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +jest.mock("@dnd-kit/core", () => ({ + closestCenter: "closestCenter", + KeyboardSensor: function KeyboardSensor() {}, + PointerSensor: function PointerSensor() {}, + useSensor: jest.fn((sensor) => sensor), + useSensors: jest.fn((...sensors) => sensors) +})); + +jest.mock("@dnd-kit/sortable", () => ({ + sortableKeyboardCoordinates: jest.fn(), + arrayMove: (arr, from, to) => { + const result = [...arr]; + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; + } +})); + +import React from "react"; +import { render } from "@testing-library/react"; +import useDndKitReorder from "../useDndKitReorder"; + +// @testing-library/react in this repo predates renderHook, so exercise the +// hook through a throwaway host component instead. +const Harness = ({ onReady, ...hookArgs }) => { + onReady(useDndKitReorder(hookArgs)); + return null; +}; + +const setup = (hookArgs) => { + let result; + render( { result = r; }} />); + return result; +}; + +const items = [ + { id: 1, name: "A" }, + { id: 2, name: "B" }, + { id: 3, name: "C" } +]; + +describe("useDndKitReorder", () => { + test("exposes sensors and the shared collision detection strategy", () => { + const result = setup({ items, getItemId: (i) => String(i.id), onReorder: jest.fn() }); + + expect(result.sensors).toBeDefined(); + expect(result.collisionDetection).toBe("closestCenter"); + }); + + test("computes order as idx + 1 when no orderOffset is given", () => { + const onReorder = jest.fn(); + const result = setup({ items, getItemId: (i) => String(i.id), onReorder }); + + result.handleDragEnd({ active: { id: "1" }, over: { id: "3" } }); + + expect(onReorder).toHaveBeenCalledWith( + [ + { id: 2, name: "B", order: 1 }, + { id: 3, name: "C", order: 2 }, + { id: 1, name: "A", order: 3 } + ], + expect.objectContaining({ oldIndex: 0, newIndex: 2 }) + ); + }); + + test("adds orderOffset on top of the base position for paginated consumers", () => { + const onReorder = jest.fn(); + const result = setup({ + items, + getItemId: (i) => String(i.id), + onReorder, + orderOffset: 10 + }); + + result.handleDragEnd({ active: { id: "1" }, over: { id: "2" } }); + + const [reordered] = onReorder.mock.calls[0]; + expect(reordered[0]).toHaveProperty("order", 11); + expect(reordered[1]).toHaveProperty("order", 12); + }); + + test("does not call onReorder when there is no drop target", () => { + const onReorder = jest.fn(); + const result = setup({ items, getItemId: (i) => String(i.id), onReorder }); + + result.handleDragEnd({ active: { id: "1" }, over: null }); + + expect(onReorder).not.toHaveBeenCalled(); + }); + + test("does not call onReorder when dropped on the same item", () => { + const onReorder = jest.fn(); + const result = setup({ items, getItemId: (i) => String(i.id), onReorder }); + + result.handleDragEnd({ active: { id: "1" }, over: { id: "1" } }); + + expect(onReorder).not.toHaveBeenCalled(); + }); + + test("skips writing an order key when updateOrderKey is falsy", () => { + const onReorder = jest.fn(); + const result = setup({ + items, + getItemId: (i) => String(i.id), + onReorder, + updateOrderKey: null + }); + + result.handleDragEnd({ active: { id: "1" }, over: { id: "2" } }); + + const [reordered] = onReorder.mock.calls[0]; + expect(reordered[0]).not.toHaveProperty("order"); + }); +}); diff --git a/src/components/mui/DragNDropList/hooks/useDndKitReorder.js b/src/components/mui/DragNDropList/hooks/useDndKitReorder.js new file mode 100644 index 00000000..cd92c7b9 --- /dev/null +++ b/src/components/mui/DragNDropList/hooks/useDndKitReorder.js @@ -0,0 +1,59 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import { + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates, arrayMove } from "@dnd-kit/sortable"; + +// Shared dnd-kit wiring for reorderable lists/tables: sensors, collision +// detection, and the base order computation (orderOffset + idx + 1). +// orderOffset lets a paginated consumer add its page offset on top without +// duplicating the sensor/collision setup or the id-matching logic. +const useDndKitReorder = ({ + items, + getItemId, + onReorder, + updateOrderKey = "order", + orderOffset = 0 +}) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const handleDragEnd = ({ active, over }) => { + if (!over || active.id === over.id) return; + + const oldIndex = items.findIndex((item, i) => getItemId(item, i) === active.id); + const newIndex = items.findIndex((item, i) => getItemId(item, i) === over.id); + + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) => + updateOrderKey + ? { ...item, [updateOrderKey]: orderOffset + idx + 1 } + : item + ); + + onReorder(reordered, { active, over, oldIndex, newIndex }); + }; + + return { sensors, collisionDetection: closestCenter, handleDragEnd }; +}; + +export default useDndKitReorder; diff --git a/src/components/mui/DragNDropList/index.js b/src/components/mui/DragNDropList/index.js index 9bd7cea3..3fd5b57b 100644 --- a/src/components/mui/DragNDropList/index.js +++ b/src/components/mui/DragNDropList/index.js @@ -13,23 +13,12 @@ import React from "react"; import PropTypes from "prop-types"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - arrayMove -} from "@dnd-kit/sortable"; +import { DndContext } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { Box } from "@mui/material"; import SortableItem from "./sortable-item"; +import useDndKitReorder from "./hooks/useDndKitReorder"; // Items without an idKey value fall back to a positional id (new-${index}). // Because that id is recomputed from the current index every render, after a @@ -51,35 +40,17 @@ const DragNDropList = ({ idKey = "id", updateOrderKey = "order" }) => { - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ); - - const handleDragEnd = ({ active, over }) => { - if (!over || active.id === over.id) return; - - const oldIndex = items.findIndex( - (item, i) => getItemId(item, i, idKey) === active.id - ); - const newIndex = items.findIndex( - (item, i) => getItemId(item, i, idKey) === over.id - ); - - if (oldIndex === -1 || newIndex === -1) return; - - const reordered = arrayMove(items, oldIndex, newIndex).map((item, i) => ({ - ...item, - [updateOrderKey]: i + 1 - })); - - onReorder(reordered, { active, over }); - }; + const { sensors, collisionDetection, handleDragEnd } = useDndKitReorder({ + items, + getItemId: (item, i) => getItemId(item, i, idKey), + updateOrderKey, + onReorder: (reordered, { active, over }) => onReorder(reordered, { active, over }) + }); return ( { 12 ); }); + + test("falls back to a page-agnostic order when pagination props are not wired up", () => { + const onReorder = jest.fn(); + + render( + + ); + + global.__triggerDragEnd({ active: { id: "1" }, over: { id: "2" } }); + + expect(onReorder).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 2, order: 1 }), + expect.objectContaining({ id: 1, order: 2 }) + ]), + 1, + 2 + ); + expect(onReorder).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + NaN + ); + }); }); }); diff --git a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js index e3b2d6b2..54d8a96e 100644 --- a/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js +++ b/src/components/mui/sortable-table-v2/mui-table-sortable-v2.js @@ -27,20 +27,8 @@ import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; import { IconButton } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - arrayMove -} from "@dnd-kit/sortable"; +import { DndContext } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { visuallyHidden } from "@mui/utils"; import styles from "./styles.module.less"; @@ -53,6 +41,7 @@ import { import showConfirmDialog from "../showConfirmDialog"; import SortableRow from "./sortable-row"; import TableCellContent from "../table/table-cell-content"; +import useDndKitReorder from "../DragNDropList/hooks/useDndKitReorder"; const getRowId = (row, index, idKey) => row[idKey] !== undefined && row[idKey] !== null @@ -98,40 +87,29 @@ const MuiTableSortableV2 = ({ const { sortCol, sortDir } = options; - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ); - - const handleDragEnd = ({ active, over }) => { - if (!over || active.id === over.id) return; - - const oldIndex = data.findIndex( - (row, i) => getRowId(row, i, idKey) === active.id - ); - const newIndex = data.findIndex( - (row, i) => getRowId(row, i, idKey) === over.id - ); + // Falls back to a page-agnostic order (idx + 1) when pagination isn't + // wired up, instead of writing NaN into every row's order on drag-end. + const pageOffset = + currentPage != null && perPage != null ? (currentPage - 1) * perPage : 0; - if (oldIndex === -1 || newIndex === -1) return; + const { sensors, collisionDetection, handleDragEnd } = useDndKitReorder({ + items: data, + getItemId: (row, i) => getRowId(row, i, idKey), + updateOrderKey, + orderOffset: pageOffset, + onReorder: (reordered, { oldIndex }) => { + const movedItem = data[oldIndex]; + const movedItemId = movedItem?.[idKey] ?? movedItem?.id; - const movedItem = data[oldIndex]; - const movedItemId = movedItem?.[idKey] ?? movedItem?.id; + const newOrder = updateOrderKey + ? reordered.find((item) => (item[idKey] ?? item.id) === movedItemId)?.[ + updateOrderKey + ] + : undefined; - const reordered = arrayMove(data, oldIndex, newIndex).map((item, idx) => - updateOrderKey - ? { ...item, [updateOrderKey]: (currentPage - 1) * perPage + idx + 1 } - : item - ); - - const newOrder = updateOrderKey - ? reordered.find((item) => (item[idKey] ?? item.id) === movedItemId)?.[ - updateOrderKey - ] - : undefined; - - onReorder?.(reordered, movedItemId, newOrder); - }; + onReorder?.(reordered, movedItemId, newOrder); + } + }); const handleDelete = async (item) => { const isConfirmed = await showConfirmDialog({ @@ -206,7 +184,7 @@ const MuiTableSortableV2 = ({ {/* TABLE BODY */}