diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts index ece7b3b7fb..f6e964cd65 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -26,6 +26,17 @@ class CreateConnectionTypeTableRow extends TableRow { findRequired() { return this.find().findByTestId('field-required'); } + + dragToIndex(i: number) { + const dataTransfer = new DataTransfer(); + this.find().trigger('dragstart', { dataTransfer }); + createConnectionTypePage + .getFieldsTableRow(i) + .find() + .trigger('dragover', { dataTransfer }) + .trigger('drop', { dataTransfer }) + .trigger('dragend'); + } } class CreateConnectionTypePage { @@ -39,6 +50,11 @@ class CreateConnectionTypePage { cy.findAllByText('Create connection type').should('exist'); } + visitEditPage(name = 'existing') { + cy.visitWithLogin(`/connectionTypes/edit/${name}`); + cy.findAllByText('Create connection type').should('exist'); + } + findConnectionTypeName() { return cy.findByTestId('connection-type-name'); } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts index 110fe1815e..990de9d30f 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts @@ -5,6 +5,8 @@ import { import { createConnectionTypePage } from '~/__tests__/cypress/cypress/pages/connectionTypes'; import { asProductAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; import { mockDashboardConfig } from '~/__mocks__'; +import type { ConnectionTypeField } from '~/concepts/connectionTypes/types'; +import { toConnectionTypeConfigMap } from '~/concepts/connectionTypes/utils'; describe('create', () => { beforeEach(() => { @@ -99,3 +101,65 @@ describe('duplicate', () => { row2.findRequired().should('be.checked'); }); }); + +describe('edit', () => { + const existing = mockConnectionTypeConfigMapObj({ + name: 'existing', + fields: [ + { + type: 'section', + name: 'header1', + }, + { + type: 'short-text', + name: 'field1', + envVar: 'short-text-1', + required: false, + properties: {}, + }, + { + type: 'short-text', + name: 'field2', + envVar: 'short-text-2', + required: true, + properties: {}, + }, + ] as ConnectionTypeField[], + }); + + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh( + 'GET /api/connection-types/:name', + { path: { name: 'existing' } }, + toConnectionTypeConfigMap(existing), + ); + }); + + it('Drag and drop field rows in table', () => { + createConnectionTypePage.visitEditPage('existing'); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'header1'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'field2'); + + createConnectionTypePage.getFieldsTableRow(0).dragToIndex(2); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field2'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'header1'); + + createConnectionTypePage.getFieldsTableRow(1).dragToIndex(0); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'field2'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'header1'); + }); +}); diff --git a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx index f77889d3e3..089a56047b 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx @@ -15,6 +15,7 @@ import { import { PlusCircleIcon } from '@patternfly/react-icons'; import { Table, Thead, Tbody, Tr, Th } from '@patternfly/react-table'; import { ConnectionTypeField, ConnectionTypeFieldType } from '~/concepts/connectionTypes/types'; +import useDraggableTableControlled from '~/utilities/useDraggableTableControlled'; import ConnectionTypeFieldModal from './ConnectionTypeFieldModal'; import ManageConnectionTypeFieldsTableRow from './ManageConnectionTypeFieldsTableRow'; @@ -63,21 +64,26 @@ const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChan 'Required', ]; - // TODO: drag and drop rows + const { tableProps, rowsToRender } = useDraggableTableControlled( + fields, + onFieldsChange, + ); + return ( <> {fields.length > 0 ? ( <> - +
+ ))} - - {fields.map((row, index) => ( + + {rowsToRender.map(({ data: row, rowProps }, index) => ( = ({ fields, onFieldsChan setModalField({ field: row, isEdit: true, + index, }); }} - onDelete={() => onFieldsChange(fields.filter((f) => f !== row))} + onDelete={() => onFieldsChange(fields.filter((f, i) => i !== index))} onDuplicate={(field) => { setModalField({ field: structuredClone(field), @@ -111,6 +118,7 @@ const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChan ...fields.slice(index + 1), ]); }} + {...rowProps} /> ))} @@ -148,10 +156,13 @@ const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChan onClose={() => setModalField(undefined)} isOpen onSubmit={(field) => { - const i = modalField.field ? fields.indexOf(modalField.field) : -1; - if (i >= 0) { + if (modalField.field && modalField.isEdit && modalField.index !== undefined) { // update - onFieldsChange([...fields.slice(0, i), field, ...fields.slice(i + 1)]); + onFieldsChange([ + ...fields.slice(0, modalField.index), + field, + ...fields.slice(modalField.index + 1), + ]); } else if (modalField.index != null) { // insert onFieldsChange([ diff --git a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx index 7f4962bc5e..a4cf4556c7 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { Button, Label, Switch, Text, TextContent, Truncate } from '@patternfly/react-core'; +import { Button, Label, Switch, Truncate } from '@patternfly/react-core'; import { ConnectionTypeField, ConnectionTypeFieldType, SectionField, } from '~/concepts/connectionTypes/types'; import { defaultValueToString, fieldTypeToString } from '~/concepts/connectionTypes/utils'; +import type { RowProps } from '~/utilities/useDraggableTableControlled'; type Props = { row: ConnectionTypeField; @@ -16,7 +17,7 @@ type Props = { onDuplicate: (field: ConnectionTypeField) => void; onAddField: (parentSection: SectionField) => void; onChange: (updatedField: ConnectionTypeField) => void; -}; +} & RowProps; const ManageConnectionTypeFieldsTableRow: React.FC = ({ row, @@ -26,18 +27,24 @@ const ManageConnectionTypeFieldsTableRow: React.FC = ({ onDuplicate, onAddField, onChange, + ...props }) => { if (row.type === ConnectionTypeFieldType.Section) { return ( - + + + +
{columns.map((column, columnIndex) => ( {column}
{row.name}{' '} - - {row.description} - +
+ +
{row.name} - - - - - +
+ +
{fieldTypeToString(row)} diff --git a/frontend/src/utilities/useDraggableTableControlled.ts b/frontend/src/utilities/useDraggableTableControlled.ts new file mode 100644 index 0000000000..f2c9e70484 --- /dev/null +++ b/frontend/src/utilities/useDraggableTableControlled.ts @@ -0,0 +1,176 @@ +import React from 'react'; +import styles from '@patternfly/react-styles/css/components/Table/table'; + +export type Row = { + data: T; + id: string; +}; + +export type RowProps = { + onDragStart: React.DragEventHandler; + onDragEnd: React.DragEventHandler; + onDrop: React.DragEventHandler; + id: string; +}; + +type DraggableTableControlled = { + tableProps: { + className: string | undefined; + tbodyProps: { + onDragOver: React.DragEventHandler; + onDragLeave: React.DragEventHandler; + ref: React.MutableRefObject; + }; + }; + rowsToRender: { + data: T; + rowProps: RowProps; + }[]; +}; + +const useDraggableTableControlled = ( + savedItemOrder: T[], + setSavedItemOrder: (itemOrder: T[]) => void, +): DraggableTableControlled => { + const [draggedItemId, setDraggedItemId] = React.useState(''); + const [draggingToItemIndex, setDraggingToItemIndex] = React.useState(-1); + const [isDragging, setIsDragging] = React.useState(false); + const bodyRef = React.useRef(null); + + const [tempItemOrder, setTempItemOrder] = React.useState[]>([]); + + React.useEffect(() => { + setTempItemOrder(savedItemOrder.map((r, i) => ({ data: r, id: String(i) }))); + }, [savedItemOrder]); + + const onDragStart = React.useCallback>( + (assignableEvent) => { + assignableEvent.dataTransfer.effectAllowed = 'move'; + assignableEvent.dataTransfer.setData('text/plain', assignableEvent.currentTarget.id); + const currentDraggedItemId = assignableEvent.currentTarget.id; + + assignableEvent.currentTarget.classList.add(styles.modifiers.ghostRow); + assignableEvent.currentTarget.setAttribute('aria-pressed', 'true'); + + setDraggedItemId(currentDraggedItemId); + setIsDragging(true); + }, + [], + ); + + const moveItem = React.useCallback((arr: Row[], id: string, toIndex: number) => { + const fromIndex = arr.findIndex((row) => row.id === id); + if (fromIndex === toIndex) { + return arr; + } + const temp = arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, temp[0]); + + return arr; + }, []); + + const onDragCancel = React.useCallback(() => { + if (!bodyRef.current) { + return; + } + + Array.from(bodyRef.current.children).forEach((el) => { + el.classList.remove(styles.modifiers.ghostRow); + el.setAttribute('aria-pressed', 'false'); + }); + setDraggedItemId(''); + setDraggingToItemIndex(-1); + setIsDragging(false); + }, []); + + const isValidDrop = React.useCallback( + (evt: React.DragEvent) => { + if (!bodyRef.current) { + return; + } + const ulRect = bodyRef.current.getBoundingClientRect(); + return ( + evt.clientX > ulRect.x && + evt.clientX < ulRect.x + ulRect.width && + evt.clientY > ulRect.y && + evt.clientY < ulRect.y + ulRect.height + ); + }, + [], + ); + + const onDragLeave = React.useCallback< + DraggableTableControlled['tableProps']['tbodyProps']['onDragLeave'] + >( + (evt) => { + if (!isValidDrop(evt)) { + // move(itemOrder); + setDraggingToItemIndex(-1); + } + }, + [isValidDrop], + ); + + const onDragOver = React.useCallback< + DraggableTableControlled['tableProps']['tbodyProps']['onDragOver'] + >( + (evt) => { + evt.preventDefault(); + if (!bodyRef.current) { + return; + } + + const curListItem = evt.target instanceof HTMLElement ? evt.target.closest('tr') : null; + if ( + !curListItem || + !bodyRef.current.contains(curListItem) || + curListItem.id === draggedItemId + ) { + return; + } + const dragId = curListItem.id; + const newDraggingToItemIndex = Array.from(bodyRef.current.children).findIndex( + (item) => item.id === dragId, + ); + if (newDraggingToItemIndex !== draggingToItemIndex) { + const newItemOrder = moveItem([...tempItemOrder], draggedItemId, newDraggingToItemIndex); + setDraggingToItemIndex(newDraggingToItemIndex); + setTempItemOrder(newItemOrder); + } + }, + [draggedItemId, draggingToItemIndex, tempItemOrder, moveItem], + ); + + const onDrop = React.useCallback>( + (evt) => { + if (isValidDrop(evt)) { + setSavedItemOrder(tempItemOrder.map((i) => i.data)); + } else { + onDragCancel(); + } + }, + [isValidDrop, onDragCancel, setSavedItemOrder, tempItemOrder], + ); + + const onDragEnd = React.useCallback>((evt) => { + const target = evt.currentTarget; + target.classList.remove(styles.modifiers.ghostRow); + target.setAttribute('aria-pressed', 'false'); + setDraggedItemId(''); + setDraggingToItemIndex(-1); + setIsDragging(false); + }, []); + + return { + tableProps: { + className: isDragging ? styles.modifiers.dragOver : undefined, + tbodyProps: { onDragOver, onDragLeave, ref: bodyRef }, + }, + rowsToRender: tempItemOrder.map((i) => ({ + data: i.data, + rowProps: { id: i.id, onDragStart, onDragEnd, onDrop }, + })), + }; +}; + +export default useDraggableTableControlled;