diff --git a/v3/cypress/e2e/hierarchical-table.spec.ts b/v3/cypress/e2e/hierarchical-table.spec.ts index e403cd26d2..b0eaaa40e6 100644 --- a/v3/cypress/e2e/hierarchical-table.spec.ts +++ b/v3/cypress/e2e/hierarchical-table.spec.ts @@ -1,4 +1,5 @@ import { TableTileElements as table } from "../support/elements/table-tile" +import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements" import hierarchical from '../fixtures/hierarchical.json' type HierarchicalTest = typeof hierarchical.tests[number] & { only?: boolean } @@ -6,7 +7,7 @@ const values = hierarchical.attributes context("hierarchical collections", () => { beforeEach(function () { - const queryParams = "?sample=mammals&dashboard&mouseSensor" + const queryParams = "?sample=mammals&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(1000) @@ -61,4 +62,43 @@ context("hierarchical collections", () => { // table.getNumOfRows(1).should("contain", 15) // table.getNumOfRows(2).should("contain", 31) }) + + it("verify insert case in hierarchical table", () => { + table.moveAttributeToParent("Order", "newCollection") + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 29) + + // Insert a new case + table.openIndexMenuForRow(3, 2) + table.insertCase() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 30) + + // delete the new case + table.openIndexMenuForRow(3, 2) + table.deleteCase() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 29) + + // Undo delete + toolbar.getUndoTool().click() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 30) + + // Undo insert + toolbar.getUndoTool().click() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 29) + + // Redo insert + toolbar.getRedoTool().click() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 30) + + // Redo delete + toolbar.getRedoTool().click() + table.getNumOfRows(1).should("contain", 14) + table.getNumOfRows(2).should("contain", 29) + }) + }) diff --git a/v3/cypress/e2e/table.spec.ts b/v3/cypress/e2e/table.spec.ts index 7f6bbe8fb2..910dd6a7cf 100644 --- a/v3/cypress/e2e/table.spec.ts +++ b/v3/cypress/e2e/table.spec.ts @@ -493,7 +493,7 @@ context("case table ui", () => { describe("index menu", () => { it("verify index menu insert case and delete case work", () => { - let initialRowCount = 0, postInsertRowCount, postDeleteRowCount + let initialRowCount = 0, postInsertRowCount = -1, postDeleteRowCount = -1 // Get initial row count table.getNumOfRows().then(rowCount => { @@ -525,21 +525,19 @@ context("case table ui", () => { toolbar.getUndoTool().click() // Verify undo (check if row count is back to post-insert count) - // TODO: add the check once bug is fixed (PT #187083170) - // table.getNumOfRows().then(rowCount => { - // const rowCountAfterUndo = Number(rowCount) - // expect(rowCountAfterUndo).to.eq(postInsertRowCount) - //}) + table.getNumOfRows().then(rowCount => { + const rowCountAfterUndo = Number(rowCount) + expect(rowCountAfterUndo).to.eq(postInsertRowCount) + }) // Redo delete toolbar.getRedoTool().click() // Verify redo (check if row count is back to initial count) - // TODO: add the check once bug is fixed (PT #187083170) - // table.getNumOfRows().then(rowCount => { - // const rowCountAfterRedo = Number(rowCount) - // expect(rowCountAfterRedo).to.eq(initialRowCount) - // }) + table.getNumOfRows().then(rowCount => { + const rowCountAfterRedo = Number(rowCount) + expect(rowCountAfterRedo).to.eq(initialRowCount) + }) }) it("verify insert cases before a row by typing num of cases", () => { @@ -604,7 +602,6 @@ context("case table ui", () => { table.openIndexMenuForRow(lastRowIndex) table.insertCase() table.getCaseTableGrid().scrollTo("bottom") - cy.wait(500) table.openIndexMenuForRow(lastRowIndex) table.deleteCase() table.getNumOfRows().should("equal", numOfCases) @@ -631,7 +628,6 @@ context("case table ui", () => { table.openIndexMenuForRow(lastRowIndex) table.insertCases(2, "after") table.getCaseTableGrid().scrollTo("bottom") - cy.wait(500) table.openIndexMenuForRow(lastRowIndex + 1) table.deleteCase() table.openIndexMenuForRow(lastRowIndex + 1) @@ -655,7 +651,6 @@ context("case table ui", () => { table.openIndexMenuForRow(lastRowIndex) table.insertCases(2, "before") table.getCaseTableGrid().scrollTo("bottom") - cy.wait(500) table.openIndexMenuForRow(lastRowIndex + 1) table.deleteCase() table.openIndexMenuForRow(lastRowIndex) @@ -693,7 +688,6 @@ context("case table ui", () => { table.openIndexMenuForRow(firstRowIndex) table.insertCase() table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(firstRowIndex) table.deleteCase() table.getNumOfRows().should("equal", numOfCases) @@ -715,7 +709,6 @@ context("case table ui", () => { table.openIndexMenuForRow(firstRowIndex) table.insertCases(3, "after") table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(firstRowIndex + 1) table.deleteCase() table.openIndexMenuForRow(firstRowIndex + 1) @@ -741,7 +734,6 @@ context("case table ui", () => { table.openIndexMenuForRow(firstRowIndex) table.insertCases(3, "before") table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(firstRowIndex) table.deleteCase() table.openIndexMenuForRow(firstRowIndex) @@ -785,7 +777,6 @@ context("case table ui", () => { table.openIndexMenuForRow(middleRowIndex) table.insertCase() table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(middleRowIndex) table.deleteCase() table.getNumOfRows().should("equal", numOfCases) @@ -807,7 +798,6 @@ context("case table ui", () => { table.openIndexMenuForRow(middleRowIndex) table.insertCases(3, "after") table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(middleRowIndex + 1) table.deleteCase() table.openIndexMenuForRow(middleRowIndex + 1) @@ -833,7 +823,6 @@ context("case table ui", () => { table.openIndexMenuForRow(middleRowIndex) table.insertCases(3, "before") table.getCaseTableGrid().scrollTo("top") - cy.wait(500) table.openIndexMenuForRow(middleRowIndex) table.deleteCase() table.openIndexMenuForRow(middleRowIndex) @@ -964,7 +953,7 @@ context("case table ui", () => { cy.log("double-clicking the cell") // double-click to initiate editing cell table.getGridCell(2, 2).dblclick() - cy.wait(1000) // Wait for the editing input to appear + cy.wait(100) // Wait for the editing input to appear cy.log("check the editing cell contents") table.getGridCell(2, 2).find("[data-testid='cell-text-editor']").should("have.value", "African Elephant") @@ -976,7 +965,7 @@ context("case table ui", () => { cy.log("double-click to begin editing cell") table.getGridCell(2, 2).click() table.getGridCell(2, 2).dblclick() - cy.wait(1000) // Wait for the editing input to appear + cy.wait(100) // Wait for the editing input to appear cy.log("click color swatch to bring up color palette") table.getGridCell(2, 2) @@ -984,11 +973,11 @@ context("case table ui", () => { .should('exist') .should('be.visible') .dblclick({ force: true }) // Double-click the button - cy.wait(1000) // Wait for the color palette to appear + cy.wait(100) // Wait for the color palette to appear cy.log("click hue bar to change color") cy.get(`.react-colorful .react-colorful__hue [aria-label="Hue"]`).should('be.visible').click() - cy.wait(1000) // Wait for the color change to be reflected + cy.wait(100) // Wait for the color change to be reflected cy.log("verify that the color actually changed") table.verifyEditCellSwatchColor(2, 2, "rgb(0, 255,") @@ -1001,7 +990,7 @@ context("case table ui", () => { cy.log("double-click to begin editing cell again") table.getGridCell(2, 2).dblclick() - cy.wait(1000) // Wait for the editing input to appear + cy.wait(100) // Wait for the editing input to appear cy.log("click color swatch to bring up color palette again") table.getGridCell(2, 2) @@ -1009,11 +998,11 @@ context("case table ui", () => { .should('exist') .should('be.visible') .dblclick({ force: true }) // Double-click the button - cy.wait(1000) // Wait for the color palette to appear + cy.wait(100) // Wait for the color palette to appear cy.log("click hue bar to change color again") cy.get(`.react-colorful .react-colorful__hue [aria-label="Hue"]`).should('be.visible').click() - cy.wait(1000) // Wait for the color change to be reflected + cy.wait(100) // Wait for the color change to be reflected cy.log("verify that the color actually changed again") table.verifyEditCellSwatchColor(2, 2, "rgb(0, 255,") diff --git a/v3/cypress/support/elements/table-tile.ts b/v3/cypress/support/elements/table-tile.ts index 4017d5b1af..570c8ec73d 100644 --- a/v3/cypress/support/elements/table-tile.ts +++ b/v3/cypress/support/elements/table-tile.ts @@ -64,7 +64,6 @@ export const TableTileElements = { insertCase() { this.getIndexMenu().should("be.visible") cy.clickMenuItem("Insert Case") - cy.wait(500) }, insertCases(num_of_cases: number, location: string) { this.getIndexMenu().should("be.visible") @@ -76,7 +75,6 @@ export const TableTileElements = { deleteCase() { this.getIndexMenu().should("be.visible") cy.clickMenuItem("Delete Case") - cy.wait(500) }, getInsertCasesModalHeader() { return cy.get("[data-testid=codap-modal-header]") diff --git a/v3/src/components/case-table/index-menu-list.tsx b/v3/src/components/case-table/index-menu-list.tsx index 043539ad14..10c289d934 100644 --- a/v3/src/components/case-table/index-menu-list.tsx +++ b/v3/src/components/case-table/index-menu-list.tsx @@ -1,10 +1,11 @@ -import { MenuItem, MenuList, useDisclosure, useToast } from "@chakra-ui/react" +import { MenuItem, MenuList, useDisclosure } from "@chakra-ui/react" import React from "react" import { useDataSetContext } from "../../hooks/use-data-set-context" -import { removeCasesWithCustomUndoRedo } from "../../models/data/data-set-undo" +import { insertCasesWithCustomUndoRedo, removeCasesWithCustomUndoRedo } from "../../models/data/data-set-undo" import { t } from "../../utilities/translation/translate" -import { InsertCasesModal } from "./insert-cases-modal" +import { IInsertSpec, InsertCasesModal } from "./insert-cases-modal" import { isItemEditable } from "../../utilities/plugin-utils" +import { ICaseCreation } from "../../models/data/data-set-types" interface IProps { caseId: string @@ -12,58 +13,73 @@ interface IProps { } export const IndexMenuList = ({caseId, index}: IProps) => { - const toast = useToast() const data = useDataSetContext() - const { isOpen, onOpen, onClose } = useDisclosure() + const { isOpen, onOpen: onOpenInsertCasesModal, onClose: onCloseInsertCasesModal } = useDisclosure() const deletableSelectedItems = data?.selection ? Array.from(data.selection).filter(itemId => isItemEditable(data, itemId)) : [] const disableEdits = deletableSelectedItems.length < 1 - const deleteCasesItemText = deletableSelectedItems.length === 1 - ? t("DG.CaseTable.indexMenu.deleteCase") - : t("DG.CaseTable.indexMenu.deleteCases") - const handleInsertCase = () => { - data?.addCases([{}], {before: caseId}) - } - - const handleInsertCases = () => { - onOpen() + function handleCloseInsertCasesModel(insertSpec?: IInsertSpec) { + const { count, position } = insertSpec || {} + if (data && count && position) { + const casesToInsert: ICaseCreation[] = Array(count).fill({}) + insertCasesWithCustomUndoRedo(data, casesToInsert, { [position]: caseId }) + } + onCloseInsertCasesModal() } - const handleMenuItemClick = (menuItem: string) => { - toast({ - title: 'Menu item clicked', - description: `You clicked on ${menuItem} on index=${index} id=${caseId}`, - status: 'success', - duration: 9000, - isClosable: true, - }) + interface IMenuItem { + itemKey: string + // defaults to true if not implemented + isEnabled?: (item: IMenuItem) => boolean + handleClick?: (item: IMenuItem) => void } - const handleDeleteCases = () => { - if (data?.selection.size) { - removeCasesWithCustomUndoRedo(data, deletableSelectedItems) + const menuItems: IMenuItem[] = [ + { + itemKey: "DG.CaseTable.indexMenu.moveEntryRow" + }, + { + itemKey: "DG.CaseTable.indexMenu.insertCase", + isEnabled: () => !disableEdits, + handleClick: () => { + if (data) { + insertCasesWithCustomUndoRedo(data, [{}], { before: caseId }) + } + } + }, + { + itemKey: "DG.CaseTable.indexMenu.insertCases", + isEnabled: () => !disableEdits, + handleClick: () => onOpenInsertCasesModal() + }, + { + itemKey: `DG.CaseTable.indexMenu.delete${deletableSelectedItems.length === 1 ? "Case" : "Cases" }`, + isEnabled: () => deletableSelectedItems.length >= 1, + handleClick: () => { + if (data?.selection.size) { + removeCasesWithCustomUndoRedo(data, deletableSelectedItems) + } + } } - } + ] return ( <> - - handleMenuItemClick("Move Data Entry Row")}> - {t("DG.CaseTable.indexMenu.moveEntryRow")} - - - {t("DG.CaseTable.indexMenu.insertCase")} - - - {t("DG.CaseTable.indexMenu.insertCases")} - - - {deleteCasesItemText} - + + { + menuItems.map(item => { + const isDisabled = !item.handleClick || item.isEnabled?.(item) === false + return ( + item.handleClick?.(item)}> + {`${t(item.itemKey)}${item.handleClick ? "" : " 🚧"}`} + + ) + }) + } - + ) } diff --git a/v3/src/components/case-table/insert-cases-modal.tsx b/v3/src/components/case-table/insert-cases-modal.tsx index 8b2d544326..70c108a927 100644 --- a/v3/src/components/case-table/insert-cases-modal.tsx +++ b/v3/src/components/case-table/insert-cases-modal.tsx @@ -1,29 +1,33 @@ +import { + Button, FormControl, FormLabel, HStack, ModalBody, ModalCloseButton, ModalFooter, ModalHeader, + NumberDecrementStepper, NumberIncrementStepper, NumberInput, NumberInputField, NumberInputStepper, + Radio, RadioGroup, Tooltip +} from "@chakra-ui/react" import React, { useRef, useState } from "react" -import { Button, FormControl, FormLabel, HStack, ModalBody, ModalCloseButton, ModalFooter, ModalHeader, - NumberDecrementStepper, NumberIncrementStepper, NumberInput, - NumberInputField, NumberInputStepper, Radio, RadioGroup, Tooltip } from "@chakra-ui/react" import { t } from "../../utilities/translation/translate" import { CodapModal } from "../codap-modal" -import { useDataSetContext } from "../../hooks/use-data-set-context" -import { ICaseCreation } from "../../models/data/data-set-types" + +export interface IInsertSpec { + count: number + position: "before" | "after" +} interface IProps { caseId: string isOpen: boolean - onClose: () => void + onClose: (insertSpec?: IInsertSpec) => void } export const InsertCasesModal: React.FC = ({caseId, isOpen, onClose}: IProps) => { - const data = useDataSetContext() const [numCasesToInsert, setNumCasesToInsert] = useState(1) - const [insertPosition, setInsertPosition] = useState("after") + const [insertPosition, setInsertPosition] = useState<"before" | "after">("after") const numCasesToInsertRef = useRef(null) const handleSubmit = (e: React.KeyboardEvent) => { const key = e.key if (key === "Enter") { - insertCases() + onClose({ count: numCasesToInsert, position: insertPosition }) } } @@ -35,29 +39,12 @@ export const InsertCasesModal: React.FC = setNumCasesToInsert(parseInt(value, 10)) } - const insertCases = () => { - onClose() - const casesToAdd: ICaseCreation[] = [] - if (numCasesToInsert) { - for (let i=0; i < numCasesToInsert; i++) { - casesToAdd.push({}) - } - } - data?.applyModelChange(() => { - data.addCases(casesToAdd, {[insertPosition]: caseId}) - }, { - log: `insert ${numCasesToInsert} cases in table`, - undoStringKey: "DG.Undo.caseTable.insertCases", - redoStringKey: "DG.Redo.caseTable.insertCases" - }) - } - const buttons=[{ label: t("DG.AttrFormView.cancelBtnTitle"), tooltip: t("DG.AttrFormView.cancelBtnTooltip"), - onClick: onClose }, + onClick: () => onClose() }, { label: t("DG.CaseTable.insertCasesDialog.applyBtnTitle"), tooltip: t("DG.CaseTable.insertCasesDialog.applyBtnTooltip"), - onClick: insertCases, + onClick: () => onClose({ count: numCasesToInsert, position: insertPosition }), default: true } ] @@ -65,14 +52,14 @@ export const InsertCasesModal: React.FC = onClose()} modalWidth={"215px"} modalHeight={"130px"} >
{t("DG.CaseTable.insertCasesDialog.title")}
- + onClose()} data-testid="modal-close-button"/> @@ -85,7 +72,7 @@ export const InsertCasesModal: React.FC = - + diff --git a/v3/src/components/case-table/use-index-column.tsx b/v3/src/components/case-table/use-index-column.tsx index 762ab1db88..4708ad905d 100644 --- a/v3/src/components/case-table/use-index-column.tsx +++ b/v3/src/components/case-table/use-index-column.tsx @@ -136,7 +136,7 @@ export function IndexCell({ caseId, disableMenu, index, collapsedCases, onClick } const isInputRow = caseId === kInputRowKey - const classes = clsx("codap-index-content", { collapsed: collapsedCases != null, "input-row": isInputRow }) + const classes = clsx("codap-index-content", { collapsed: !!collapsedCases, "input-row": isInputRow }) // input row if (isInputRow) { @@ -168,7 +168,7 @@ export function IndexCell({ caseId, disableMenu, index, collapsedCases, onClick - {index != null ? `${index + 1}` : ""} + {cellContents} Press Enter to open the menu. diff --git a/v3/src/models/data/data-set-undo.ts b/v3/src/models/data/data-set-undo.ts index 41ff1fdbff..ed7b95b7d7 100644 --- a/v3/src/models/data/data-set-undo.ts +++ b/v3/src/models/data/data-set-undo.ts @@ -1,12 +1,17 @@ import { IAnyStateTreeNode, resolveIdentifier } from "mobx-state-tree" +import { kItemIdPrefix, v3Id } from "../../utilities/codap-utils" import { ICustomUndoRedoPatcher } from "../history/custom-undo-redo-registry" import { HistoryEntryType } from "../history/history" import { ICustomPatch } from "../history/tree-types" import { withCustomUndoRedo } from "../history/with-custom-undo-redo" -import { ICase, IItem } from "./data-set-types" +import { ICollectionModel } from "./collection" +import { CaseInfo, IAddCasesOptions, ICase, ICaseCreation, IItem } from "./data-set-types" import { DataSet, IDataSet } from "./data-set" import { deleteCasesNotification, } from "./data-set-notifications" +/* + * setCaseValues custom undo/redo + */ export interface ISetCaseValuesCustomPatch extends ICustomPatch { type: "DataSet.setCaseValues" data: { @@ -49,6 +54,100 @@ export function setCaseValuesWithCustomUndoRedo(data: IDataSet, cases: ICase[], }, setCaseValuesCustomUndoRedoPatcher) } +/* + * insertCases custom undo/redo + */ +interface IInsertCasesCustomPatch extends ICustomPatch { + type: "DataSet.insertCases", + data: { + dataId: string // DataSet id + items: IItem[] + options: IAddCasesOptions + } +} +function isInsertCasesCustomPatch(patch: ICustomPatch): patch is IInsertCasesCustomPatch { + return patch.type === "DataSet.insertCases" +} + +const insertCasesCustomUndoRedo: ICustomUndoRedoPatcher = { + undo: (node: IAnyStateTreeNode, patch: ICustomPatch, entry: HistoryEntryType) => { + if (isInsertCasesCustomPatch(patch)) { + const data = resolveIdentifier(DataSet, node, patch.data.dataId) + const itemIds = patch.data.items.map(({ __id__ }) => __id__) + if (data && itemIds.length) { + data.removeCases(itemIds) + } + } + }, + redo: (node: IAnyStateTreeNode, patch: ICustomPatch, entry: HistoryEntryType) => { + if (isInsertCasesCustomPatch(patch)) { + const data = resolveIdentifier(DataSet, node, patch.data.dataId) + data?.addCases(patch.data.items, patch.data.options) + } + } +} + +export function insertCasesWithCustomUndoRedo(data: IDataSet, cases: ICaseCreation[], _options: IAddCasesOptions = {}) { + data.validateCases() + + const options = { ..._options } + + let siblingCaseId: Maybe + let caseInfo: Maybe + let collection: Maybe + if (options.before) { + caseInfo = data.caseInfoMap.get(options.before) + collection = data.getCollection(caseInfo?.collectionId) + if (collection && caseInfo) { + siblingCaseId = options.before + options.before = caseInfo.childItemIds[0] + } + } else if (options.after) { + caseInfo = data.caseInfoMap.get(options.after) + collection = data.getCollection(caseInfo?.collectionId) + if (collection && caseInfo) { + siblingCaseId = options.after + options.after = caseInfo.childItemIds[caseInfo.childItemIds.length - 1] + } + } + + // add ids if they're not already present + const items: IItem[] = cases.map(aCase => ({ __id__: v3Id(kItemIdPrefix), ...aCase })) + + // add parent case values + const parentCollection = collection?.parent + const parentCase = siblingCaseId + ? parentCollection?.caseGroups.find(group => group.childCaseIds?.includes(siblingCaseId)) + : undefined + const parentCaseId = parentCase?.groupedCase.__id__ + if (parentCollection && parentCaseId) { + parentCollection.allDataAttributes.forEach(attr => { + items.forEach(item => { + item[attr.id] = data.getValue(parentCaseId, attr.id) + }) + }) + } + + const undoRedoPatch: IInsertCasesCustomPatch = { + type: "DataSet.insertCases", + data: { dataId: data.id, items, options } + } + + // insert the items + data.applyModelChange(() => { + withCustomUndoRedo(undoRedoPatch, insertCasesCustomUndoRedo) + + data.addCases(items, options) + }, { + // notify: insertCasesNotification(data, cases), + undoStringKey: "DG.Undo.caseTable.insertCases", + redoStringKey: "DG.Redo.caseTable.insertCases" + }) +} + +/* + * removeCases custom undo/redo + */ interface IItemBatch { beforeId?: string items: IItem[]