diff --git a/v3/cypress/e2e/plugin.spec.ts b/v3/cypress/e2e/plugin.spec.ts index 5db61eaf4e..298cdb5460 100644 --- a/v3/cypress/e2e/plugin.spec.ts +++ b/v3/cypress/e2e/plugin.spec.ts @@ -228,7 +228,7 @@ context("codap plugins", () => { webView.clearAPITesterResponses() cy.log("Broadcast deleteAttributes notifications") - table.deleteAttrbute("newAttr2") + table.deleteAttribute("newAttr2") webView.confirmAPITesterResponseContains(/"operation":\s"deleteAttributes/) webView.clearAPITesterResponses() @@ -321,10 +321,10 @@ context("codap plugins", () => { cfm.openExampleDocument("Four Seals") cy.wait(2000) table.getTableTile().should("contain.text", "Tracks/Measurements") - table.deleteAttrbute("species") + table.deleteAttribute("species") openAPITester() webView.toggleAPITesterFilter() - table.deleteAttrbute("animal_id") + table.deleteAttribute("animal_id") webView.confirmAPITesterResponseContains(/"operation":\s"deleteCollection/) // TODO Check for deleteCollection notifications when deleting the last attribute diff --git a/v3/cypress/e2e/table.spec.ts b/v3/cypress/e2e/table.spec.ts index d44066e6ff..1a6107deaa 100644 --- a/v3/cypress/e2e/table.spec.ts +++ b/v3/cypress/e2e/table.spec.ts @@ -380,6 +380,51 @@ context("case table ui", () => { table.getColumnHeader(1).should("contain", "Animal") table.getAttribute("Animal").should("exist") }) + it("edits, re-randomizes, and deletes formulas", () => { + // add a random() formula + table.addFormula("Height", "random()") + let random1 = 0 + table.getGridCell(2, 5).then(cell => { + random1 = +cell.text() + expect(random1 >= 0).to.eq(true) + expect(random1 < 1).to.eq(true) + }) + // Rerandomize + let random2 = 0 + table.openAttributeMenu("Height") + table.selectMenuItemFromAttributeMenu("Rerandomize") + table.getGridCell(2, 5).then(cell => { + random2 = +cell.text() + expect(random2 >= 0).to.eq(true) + expect(random2 < 1).to.eq(true) + expect(random2).not.to.eq(random1) + }) + // Delete formula, verify values remain + table.openAttributeMenu("Height") + table.selectMenuItemFromAttributeMenu("Delete Formula (Keeping Values)") + table.getGridCell(2, 5).then(cell => { + const value = +cell.text() + expect(value >= 0).to.eq(true) + expect(value < 1).to.eq(true) + expect(value).to.eq(random2) + }) + // verify that formula was deleted + table.openAttributeMenu("Height") + table.getAttributeMenuItem("Rerandomize").should("be.disabled") + table.getAttributeMenuItem("Delete Formula (Keeping Values)").should("be.disabled") + // Undo formula deletion + toolbar.getUndoTool().click() + table.openAttributeMenu("Height") + table.getAttributeMenuItem("Rerandomize").should("be.enabled") + table.getAttributeMenuItem("Delete Formula (Keeping Values)").should("be.enabled") + table.getGridCell(2, 5).then(cell => { + const value = +cell.text() + expect(value >= 0).to.eq(true) + expect(value < 1).to.eq(true) + // restored formula is re-evaluated resulting in a different value + expect(value).not.to.eq(random2) + }) + }) it("verify hide and showAll attribute with undo and redo", () => { // Hide the attribute diff --git a/v3/cypress/support/elements/table-tile.ts b/v3/cypress/support/elements/table-tile.ts index 00e8be50b4..cc522dc63a 100644 --- a/v3/cypress/support/elements/table-tile.ts +++ b/v3/cypress/support/elements/table-tile.ts @@ -92,7 +92,7 @@ export const TableTileElements = { getAttributeInput(collectionIndex = 1) { return this.getCollection(collectionIndex).find("[data-testid=column-name-input]") }, - getCasetableAttribute(name) { + getCaseTableAttribute(name) { return this.getTableTile().find(`[data-testid^="codap-attribute-button ${name}"]`) }, openAttributeMenu(name, collectionIndex = 1) { @@ -342,7 +342,7 @@ export const TableTileElements = { .click({force:true}) cy.get("[data-testid=column-name-input]").type("{enter}") }, - deleteAttrbute(attributeName, collectionIndex = 1) { + deleteAttribute(attributeName, collectionIndex = 1) { this.openAttributeMenu(attributeName, collectionIndex) this.selectMenuItemFromAttributeMenu("Delete Attribute") this.getAttribute(attributeName, collectionIndex).should("not.exist") diff --git a/v3/cypress/support/helpers/formula-helper.ts b/v3/cypress/support/helpers/formula-helper.ts index 0fa934376f..f774450d4f 100644 --- a/v3/cypress/support/helpers/formula-helper.ts +++ b/v3/cypress/support/helpers/formula-helper.ts @@ -20,7 +20,7 @@ export const FormulaHelper = { table.renameAttribute(currentAttributeName, newAttributeName, collectionIndex) }, deleteAttribute(attributeName: string, collectionIndex = 1) { - table.deleteAttrbute(attributeName, collectionIndex) + table.deleteAttribute(attributeName, collectionIndex) }, addFormula(attributeName: string, formula: string, collectionIndex = 1) { table.addFormula(attributeName, formula, collectionIndex) diff --git a/v3/src/components/case-table/attribute-menu/attribute-menu-list.tsx b/v3/src/components/case-table/attribute-menu/attribute-menu-list.tsx index b794a34ef1..406657ddef 100644 --- a/v3/src/components/case-table/attribute-menu/attribute-menu-list.tsx +++ b/v3/src/components/case-table/attribute-menu/attribute-menu-list.tsx @@ -1,6 +1,6 @@ -import React, { forwardRef } from "react" +import { MenuItem, MenuList, useDisclosure } from "@chakra-ui/react" import { observer } from "mobx-react-lite" -import { MenuItem, MenuList, useDisclosure, useToast } from "@chakra-ui/react" +import React, { forwardRef } from "react" import { useCaseMetadata } from "../../../hooks/use-case-metadata" import { useDataSetContext } from "../../../hooks/use-data-set-context" import { @@ -11,103 +11,34 @@ import { allowAttributeDeletion, preventCollectionReorg, preventTopLevelReorg } from "../../../utilities/plugin-utils" import { t } from "../../../utilities/translation/translate" -import { TCalculatedColumn } from "../case-table-types" +import { TColumn } from "../case-table-types" import { EditAttributePropertiesModal } from "./edit-attribute-properties-modal" import { EditFormulaModal } from "./edit-formula-modal" interface IProps { - column: TCalculatedColumn + column: TColumn onRenameAttribute: () => void onModalOpen: (open: boolean) => void } const AttributeMenuListComp = forwardRef( ({ column, onRenameAttribute, onModalOpen }, ref) => { - const toast = useToast() const data = useDataSetContext() const caseMetadata = useCaseMetadata() // each use of useDisclosure() maintains its own state and callbacks so they can be used for independent dialogs - const attributePropsModal = useDisclosure() + const propertiesModal = useDisclosure() const formulaModal = useDisclosure() - const columnName = column.name as string - const columnId = column.key - const attribute = data?.attrFromID(columnId) - const rerandomizeDisabled = !attribute?.formula?.isRandomFunctionPresent - - const handleMenuItemClick = (menuItem: string) => { - // TODO Don't forget to broadcast notifications as these menu items are implemented! - toast({ - title: 'Menu item clicked', - description: `You clicked on ${menuItem} on ${columnName}`, - status: 'success', - duration: 5000, - isClosable: true, - }) - } - - // can't hide last attribute of collection - const collection = data?.getCollectionForAttribute(columnId) - const visibleAttributes = collection?.attributes - .reduce((sum, attr) => attr && !caseMetadata?.isHidden(attr.id) ? sum + 1 : sum, 0) ?? 0 - const disableHideAttribute = visibleAttributes <= 1 - - const handleHideAttribute = () => { - caseMetadata?.applyModelChange( - () => caseMetadata?.setIsHidden(column.key, true), - { - notify: hideAttributeNotification([column.key], data), - undoStringKey: "DG.Undo.caseTable.hideAttribute", - redoStringKey: "DG.Redo.caseTable.hideAttribute" - } - ) - } - - const handleDeleteAttribute = () => { - const attrId = column.key - const attributeToDelete = data?.attrFromID(attrId) - if (data && attributeToDelete) { - let result: IAttributeChangeResult | undefined - // instantiate values so they're captured by undo/redo patches - attributeToDelete.prepareSnapshot() - // delete the attribute - data.applyModelChange(() => { - result = data.removeAttribute(attrId) - }, { - notify: () => { - const notifications = [removeAttributesNotification([attrId], data)] - if (result?.removedCollectionId) notifications.unshift(deleteCollectionNotification(data)) - return notifications - }, - undoStringKey: "DG.Undo.caseTable.deleteAttribute", - redoStringKey: "DG.Redo.caseTable.deleteAttribute" - }) - attributeToDelete.completeSnapshot() - } - } - - const isDeleteAttributeDisabled = () => { - if (!data) return true - - // If preventTopLevelReorg is true... - if (preventTopLevelReorg(data)) { - // Disabled if in the parent collection - if (preventCollectionReorg(data, collection?.id)) return true + const attributeId = column.key + const attribute = data?.getAttribute(attributeId) + const collection = data?.getCollectionForAttribute(attributeId) - // Disabled if there is only one attribute not in the parent collection - if (data.attributes.length - data.collections[0].attributes.length <= 1) return true - } - - return !allowAttributeDeletion(data, attribute) - } - const disableDeleteAttribute = isDeleteAttributeDisabled() - - const handleEditAttributePropsOpen = () => { - attributePropsModal.onOpen() + const handleEditPropertiesOpen = () => { + propertiesModal.onOpen() onModalOpen(true) } - const handleEditAttributePropsClose = () => { - attributePropsModal.onClose() + const handleEditPropertiesClose = () => { + propertiesModal.onClose() onModalOpen(false) } @@ -121,54 +52,142 @@ const AttributeMenuListComp = forwardRef( onModalOpen(false) } - const handleRerandomize = () => { - attribute?.formula?.rerandomize() - } - const handleMenuKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation() } + interface IMenuItem { + itemString: string + // defaults to true if not implemented + isEnabled?: () => boolean + handleClick?: () => void + } + + const menuItems: IMenuItem[] = [ + { + itemString: t("DG.TableController.headerMenuItems.renameAttribute"), + handleClick: onRenameAttribute + }, + { + itemString: t("DG.TableController.headerMenuItems.resizeColumn") + }, + { + itemString: t("DG.TableController.headerMenuItems.editAttribute"), + handleClick: handleEditPropertiesOpen + }, + { + itemString: t("DG.TableController.headerMenuItems.editFormula"), + handleClick: handleEditFormulaOpen + }, + { + itemString: t("DG.TableController.headerMenuItems.deleteFormula"), + isEnabled: () => !!(attribute?.editable && attribute?.hasFormula), + handleClick: () => { + data?.applyModelChange(() => { + attribute?.clearFormula() + }, { + // TODO Should also broadcast notify component edit formula notification + undoStringKey: "DG.Undo.caseTable.editAttributeFormula", + redoStringKey: "DG.Undo.caseTable.editAttributeFormula" + }) + } + }, + { + itemString: t("DG.TableController.headerMenuItems.recoverFormula") + }, + { + itemString: t("DG.TableController.headerMenuItems.randomizeAttribute"), + isEnabled: () => !!attribute?.formula?.isRandomFunctionPresent, + handleClick: () => { + data?.applyModelChange(() => { + attribute?.formula?.rerandomize() + }) + } + }, + { + itemString: t("DG.TableController.headerMenuItems.sortAscending") + }, + { + itemString: t("DG.TableController.headerMenuItems.sortDescending") + }, + { + itemString: t("DG.TableController.headerMenuItems.hideAttribute"), + isEnabled: () => { + // can't hide last attribute of collection + const visibleAttributes = collection?.attributes + .reduce((sum, attr) => { + return attr && !caseMetadata?.isHidden(attr.id) ? sum + 1 : sum + }, 0) ?? 0 + return visibleAttributes > 1 + }, + handleClick: () => { + caseMetadata?.applyModelChange( + () => caseMetadata?.setIsHidden(attributeId, true), + { + notify: hideAttributeNotification([attributeId], data), + undoStringKey: "DG.Undo.caseTable.hideAttribute", + redoStringKey: "DG.Redo.caseTable.hideAttribute" + } + ) + } + }, + { + itemString: t("DG.TableController.headerMenuItems.deleteAttribute"), + isEnabled: () => { + if (!data) return false + + // If preventTopLevelReorg is true... + if (preventTopLevelReorg(data)) { + // Disabled if in the parent collection + if (preventCollectionReorg(data, collection?.id)) return false + + // Disabled if there is only one attribute not in the parent collection + if (data.attributes.length - data.collections[0].attributes.length <= 1) return false + } + + return allowAttributeDeletion(data, attribute) + }, + handleClick: () => { + if (data && attribute) { + let result: IAttributeChangeResult | undefined + // instantiate values so they're captured by undo/redo patches + attribute.prepareSnapshot() + // delete the attribute + data.applyModelChange(() => { + result = data.removeAttribute(attributeId) + }, { + notify: () => { + const notifications = [removeAttributesNotification([attributeId], data)] + if (result?.removedCollectionId) notifications.unshift(deleteCollectionNotification(data)) + return notifications + }, + undoStringKey: "DG.Undo.caseTable.deleteAttribute", + redoStringKey: "DG.Redo.caseTable.deleteAttribute" + }) + attribute.completeSnapshot() + } + } + } + ] + + function isItemEnabled(item: IMenuItem) { + if (!item.handleClick) return false + if (!item.isEnabled) return true + return item.isEnabled() + } + return ( <> - - {t("DG.TableController.headerMenuItems.renameAttribute")} - - handleMenuItemClick("Fit width")}> - {t("DG.TableController.headerMenuItems.resizeColumn")} - - - {t("DG.TableController.headerMenuItems.editAttribute")} - - - {t("DG.TableController.headerMenuItems.editFormula")} - - handleMenuItemClick("Delete Formula")}> - {t("DG.TableController.headerMenuItems.deleteFormula")} - - handleMenuItemClick("Recover Formula")}> - {t("DG.TableController.headerMenuItems.recoverFormula")} - - - {t("DG.TableController.headerMenuItems.randomizeAttribute")} - - handleMenuItemClick("Sort Ascending")}> - {t("DG.TableController.headerMenuItems.sortAscending")} - - handleMenuItemClick("Sort Descending")}> - {t("DG.TableController.headerMenuItems.sortDescending")} - - - {t("DG.TableController.headerMenuItems.hideAttribute")} - - handleDeleteAttribute()} isDisabled={disableDeleteAttribute}> - {t("DG.TableController.headerMenuItems.deleteAttribute")} - + {menuItems.map(item => ( + + {`${item.itemString}${item.handleClick ? "" : " 🚧"}`} + + ))} - - + + ) })