diff --git a/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js b/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js index 5938f7526e..c120361061 100644 --- a/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js +++ b/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js @@ -468,5 +468,28 @@ context('XYPlot Tool Tile', function () { // Only the unlink remove button should remain xyTile.getRemoveVariablesButtons().should("have.length", 1); }); + + it("Test points by hand", () => { + beforeTest(queryParamsMultiDataset); + cy.log("Add XY Plot Tile"); + cy.collapseResourceTabs(); + clueCanvas.addTile("graph"); + xyTile.getTile().should('be.visible'); + + clueCanvas.clickToolbarButton("graph", "add-points-by-hand"); + xyTile.getXAttributesLabel().should('have.length', 1).should("contain.text", "X Variable"); + xyTile.getYAttributesLabel().should('have.length', 1).should("contain.text", "Y Variable 1"); + xyTile.getLayerName().should('have.length', 1).should("contain.text", "Added by hand"); + xyTile.getLayerNameInput().should('not.be.visible'); + + xyTile.getLayerNameEditButton().click(); + xyTile.getLayerNameEditButton().should('have.length', 0); + xyTile.getLayerNameInput().should('be.visible').type('Renamed{enter}'); + xyTile.getLayerNameInput().should('not.be.visible'); + xyTile.getLayerName().should('have.length', 1).should("contain.text", "Renamed"); + }); + }); + + }); diff --git a/cypress/support/elements/tile/XYPlotToolTile.js b/cypress/support/elements/tile/XYPlotToolTile.js index f43aed3872..aa97dd1a87 100644 --- a/cypress/support/elements/tile/XYPlotToolTile.js +++ b/cypress/support/elements/tile/XYPlotToolTile.js @@ -51,6 +51,15 @@ class XYPlotToolTile { getYAxisInput(workspaceClass) { return this.getAxisInput("left", workspaceClass); } + getLayerName(workspaceClass) { + return cy.get(`${wsClass(workspaceClass)} .canvas-area .multi-legend .legend-row .layer-name`); + } + getLayerNameEditButton(workspaceClass) { + return cy.get(`${wsClass(workspaceClass)} .canvas-area .multi-legend .legend-row .layer-name button`); + } + getLayerNameInput(workspaceClass) { + return cy.get(`${wsClass(workspaceClass)} .canvas-area .multi-legend .legend-row .layer-name input`); + } getXAttributesLabel(workspaceClass) { return cy.get(`${wsClass(workspaceClass)} .canvas-area .multi-legend .legend-row .bottom .simple-attribute-label`); } diff --git a/src/assets/edit.svg b/src/assets/edit.svg new file mode 100644 index 0000000000..ece95cfae5 --- /dev/null +++ b/src/assets/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/clue/app-config.json b/src/clue/app-config.json index 8360c85a63..170ff46412 100644 --- a/src/clue/app-config.json +++ b/src/clue/app-config.json @@ -269,6 +269,7 @@ "disableAttributeDnD": true, "tools": [ "link-tile", + "add-points-by-hand", "|", "fit-all", "toggle-lock" diff --git a/src/components/utilities/editable-label-with-button.scss b/src/components/utilities/editable-label-with-button.scss new file mode 100644 index 0000000000..31e2038138 --- /dev/null +++ b/src/components/utilities/editable-label-with-button.scss @@ -0,0 +1,23 @@ +@import "../vars.sass"; + +.chakra-editable { + .chakra-editable__preview[hidden] { + display: none; + } + + button { + background: inherit; + border: none; + cursor: pointer; + + &:hover { + background-color: $workspace-teal-light-6; + } + + svg { + height: 1em; + width: 1em; + vertical-align: baseline; + } + } +} diff --git a/src/components/utilities/editable-label-with-button.tsx b/src/components/utilities/editable-label-with-button.tsx new file mode 100644 index 0000000000..5b107da8b8 --- /dev/null +++ b/src/components/utilities/editable-label-with-button.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Editable, EditableInput, EditablePreview, useEditableControls } from "@chakra-ui/react"; + +import EditIcon from "../../assets/edit.svg"; + +import "./editable-label-with-button.scss"; + +interface IProps { + defaultValue: string|undefined; + onSubmit: (value:string) => void; +} + +export const EditableLabelWithButton = observer(function EditableDataSetName({defaultValue, onSubmit}: IProps) { + + function EditButton() { + const { isEditing, getEditButtonProps } = useEditableControls(); + if (!isEditing) { + return ( + + ); + } else { + return null; + } + } + + return ( + + + + + + ); +}); diff --git a/src/models/data/data-set.ts b/src/models/data/data-set.ts index a69a48e0d6..f504f6fbe8 100644 --- a/src/models/data/data-set.ts +++ b/src/models/data/data-set.ts @@ -639,6 +639,7 @@ export const DataSet = types.model("DataSet", { for (let i = attribute.values.length; i < self.cases.length; ++i) { attribute.values.push(""); } + return attribute; }, setAttributeName(attributeID: string, name: string) { @@ -882,7 +883,7 @@ export function addAttributeToDataSet(dataset: IDataSet, snapshot: IAttributeSna if (!snapshot.id) { snapshot.id = uniqueId(); } - dataset.addAttributeWithID(snapshot, beforeID); + return dataset.addAttributeWithID(snapshot, beforeID); } export function addCasesToDataSet(dataset: IDataSet, cases: ICaseCreation[], beforeID?: string | string[]) { diff --git a/src/plugins/graph/assets/add-points-by-hand-icon.svg b/src/plugins/graph/assets/add-points-by-hand-icon.svg new file mode 100644 index 0000000000..748eb9719e --- /dev/null +++ b/src/plugins/graph/assets/add-points-by-hand-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/plugins/graph/components/graph-toolbar-registration.tsx b/src/plugins/graph/components/graph-toolbar-registration.tsx index a04e5ab9dc..bac3a7901a 100644 --- a/src/plugins/graph/components/graph-toolbar-registration.tsx +++ b/src/plugins/graph/components/graph-toolbar-registration.tsx @@ -13,6 +13,7 @@ import AddIcon from "../../../assets/icons/add-data-graph-icon.svg"; import FitAllIcon from "../assets/fit-all-icon.svg"; import LockAxesIcon from "../assets/lock-axes-icon.svg"; import UnlockAxesIcon from "../assets/unlock-axes-icon.svg"; +import AddPointsByHandIcon from "../assets/add-points-by-hand-icon.svg"; function LinkTileButton(name: string, title: string, allowMultiple: boolean) { @@ -88,6 +89,34 @@ const ToggleLockAxesButton = observer(function ToggleLockAxesButton({name}: IToo ); }); +const AddPointsByHandButton = observer(function AddPointsByHandButton({name}: IToolbarButtonComponentProps) { + const graph = useGraphModelContext(); + + const hasEditableLayers = graph.getEditableLayers().length > 0; + + // Enable button if axes are numeric or undefined. + const isNumeric = (graph.attributeType("x")||"numeric") === "numeric" + && (graph.attributeType("y")||"numeric") === "numeric"; + + const enabled = isNumeric && !hasEditableLayers; + + function handleClick() { + graph.createEditableLayer(); + } + + return ( + + + + ); + +}); + registerTileToolbarButtons("graph", [ { @@ -105,5 +134,9 @@ registerTileToolbarButtons("graph", { name: 'toggle-lock', component: ToggleLockAxesButton + }, + { + name: 'add-points-by-hand', + component: AddPointsByHandButton } ]); diff --git a/src/plugins/graph/components/legend/layer-legend.tsx b/src/plugins/graph/components/legend/layer-legend.tsx index 5bd7fcfdb9..15f87b1ad0 100644 --- a/src/plugins/graph/components/legend/layer-legend.tsx +++ b/src/plugins/graph/components/legend/layer-legend.tsx @@ -12,13 +12,15 @@ import { DataConfigurationContext, useDataConfigurationContext } from "../../hoo import { IGraphLayerModel } from "../../models/graph-layer-model"; import { LegendDropdown } from "./legend-dropdown"; import { LegendIdListFunction, ILegendHeightFunctionProps, ILegendPartProps } from "./legend-types"; -import RemoveDataIcon from "../../assets/remove-data-icon.svg"; -import XAxisIcon from "../../assets/x-axis-icon.svg"; -import YAxisIcon from "../../assets/y-axis-icon.svg"; import { logSharedModelDocEvent } from "../../../../models/document/log-shared-model-document-event"; import { LogEventName } from "../../../../lib/logger-types"; import { useTileModelContext } from "../../../../components/tiles/hooks/use-tile-model-context"; +import { EditableLabelWithButton } from "../../../../components/utilities/editable-label-with-button"; +import { GraphLayerContext, useGraphLayerContext } from "../../hooks/use-graph-layer-context"; +import RemoveDataIcon from "../../assets/remove-data-icon.svg"; +import XAxisIcon from "../../assets/x-axis-icon.svg"; +import YAxisIcon from "../../assets/y-axis-icon.svg"; export const layerLegendType = "layer-legend"; @@ -46,6 +48,7 @@ function ColorKey({ color }: IColorKeyProps) { const SingleLayerLegend = observer(function SingleLayerLegend(props: ILegendPartProps) { let legendItems = [] as React.ReactNode[]; const graphModel = useGraphModelContext(); + const layer = useGraphLayerContext(); const dataConfiguration = useDataConfigurationContext(); const readOnly = useReadOnlyContext(); const { tile } = useTileModelContext(); @@ -72,6 +75,23 @@ const SingleLayerLegend = observer(function SingleLayerLegend(props: ILegendPart } } + + const dataSetName = dataConfiguration?.dataset?.name || "Unknown"; + + function handleSetDataSetName (value: string) { + if (value) { + dataConfiguration?.dataset?.setName(value); + } + } + + const layerName = + + {layer.editable + ? + : dataSetName + } + ; + if (dataConfiguration) { const yAttributes = dataConfiguration.yAttributeDescriptions; @@ -136,7 +156,7 @@ const SingleLayerLegend = observer(function SingleLayerLegend(props: ILegendPart }
- Data from: {dataConfiguration.dataset.name || "Unknown"}  + Data from: {layerName}
@@ -170,11 +190,13 @@ export const LayerLegend = observer(function LayerLegend(props: ILegendPartProps { graphModel.layers.map((layer) => { return ( - - - ); - } - ) + + + + + + ); + }) } ); diff --git a/src/plugins/graph/components/legend/multi-legend.scss b/src/plugins/graph/components/legend/multi-legend.scss index 98aad001b7..8d32bf70df 100644 --- a/src/plugins/graph/components/legend/multi-legend.scss +++ b/src/plugins/graph/components/legend/multi-legend.scss @@ -25,6 +25,20 @@ .legend-title { padding-left: 6px; + + .layer-name { + font-weight: bold; + } + + .chakra-editable { + display: inline-block; + margin-left: 6px; + + input { + font-weight: normal; + } + } + } } diff --git a/src/plugins/graph/models/graph-layer-model.ts b/src/plugins/graph/models/graph-layer-model.ts index 3de1a785c3..37128bb9c3 100644 --- a/src/plugins/graph/models/graph-layer-model.ts +++ b/src/plugins/graph/models/graph-layer-model.ts @@ -15,7 +15,9 @@ export const GraphLayerModel = types .model('GraphLayerModel') .props({ id: types.optional(types.identifier, () => typedId("LAYR")), - config: types.optional(DataConfigurationModel, () => DataConfigurationModel.create()) + config: types.optional(DataConfigurationModel, () => DataConfigurationModel.create()), + // Whether this layer contains "points by hand" that can be edited in the graph + editable: false }) .volatile(self => ({ autoAssignedAttributes: [] as Array<{ place: GraphPlace, role: GraphAttrRole, dataSetID: string, attrID: string }>, @@ -90,6 +92,7 @@ export const GraphLayerModel = types configureUnlinkedLayer() { if (!self.config.isEmpty) { self.config.clearAttributes(); + self.editable = false; } }, setDataSetListener() { diff --git a/src/plugins/graph/models/graph-model.test.ts b/src/plugins/graph/models/graph-model.test.ts index 1d1cc4f11f..c55b85ab2a 100644 --- a/src/plugins/graph/models/graph-model.test.ts +++ b/src/plugins/graph/models/graph-model.test.ts @@ -126,34 +126,50 @@ describe('GraphModel', () => { expect(graphModel.layers[1].config.dataset).toEqual(sharedDataSet2.dataSet); }); + it('supports adding an editable layer', () => { + if (!graphModel) fail('No graph model'); // reuses data from previous test + expect(graphModel.layers.length).toBe(2); + expect(graphModel.layers[0].editable).toBe(false); + graphModel.createEditableLayer(); + expect(graphModel.layers.length).toBe(3); + const layer = graphModel.layers[2]; + expect(layer.editable).toBe(true); + expect(layer.config.attributeDescriptions.x.type).toEqual("numeric"); + expect(layer.config.attributeDescriptions.y.type).toEqual("numeric"); + expect(layer.config.dataset?.name).toEqual("Added by hand"); + expect(layer.config.dataset?.attributes.map(a => a.name)).toEqual(["X Variable", "Y Variable 1"]); + }); + it('supports removing layers', () => { - if (!graphModel) fail('No graph model'); + if (!graphModel) fail('No graph model'); // reuses data from previous test const smm = getSharedModelManager(graphModel); smm?.removeTileSharedModel(graphModel, sharedDataSet2); graphModel.updateAfterSharedModelChanges(sharedDataSet); // Currently Metadata remains attached - doesn't seem like correct behavior longer term though - expect(getTileSharedModels(graphModel)).toHaveLength(3); - expect(graphModel.layers.length).toBe(1); + expect(getTileSharedModels(graphModel)).toHaveLength(5); + expect(graphModel.layers.length).toBe(2); expect(graphModel.layers[0].isLinked).toBe(true); expect(graphModel.layers[0].config.dataset).toEqual(sharedDataSet.dataSet); }); it("re-uses existing metadata if present", () => { - if (!graphModel) fail('No graph model'); + if (!graphModel) fail('No graph model'); // reuses data from previous test const smm = getSharedModelManager(graphModel); smm?.addSharedModel(sharedDataSet2); smm?.addTileSharedModel(graphModel, sharedDataSet2); graphModel.updateAfterSharedModelChanges(sharedDataSet2); - expect(getTileSharedModels(graphModel)).toHaveLength(4); - expect(graphModel.layers.length).toBe(2); + expect(getTileSharedModels(graphModel)).toHaveLength(6); + expect(graphModel.layers.length).toBe(3); expect(graphModel.layers[0].isLinked).toBe(true); expect(graphModel.layers[0].config.dataset).toEqual(sharedDataSet.dataSet); expect(graphModel.layers[1].isLinked).toBe(true); - expect(graphModel.layers[1].config.dataset).toEqual(sharedDataSet2.dataSet); + expect(graphModel.layers[1].editable).toEqual(true); + expect(graphModel.layers[2].isLinked).toBe(true); + expect(graphModel.layers[2].config.dataset).toEqual(sharedDataSet2.dataSet); }); it("cycles through colors properly", () => { - if (!graphModel) fail("No graph model"); + if (!graphModel) fail("No graph model"); // reuses data from previous test function getUniqueColorIndices() { const uniqueColorIndices: number[] = []; graphModel._idColors.forEach(colorIndex => { diff --git a/src/plugins/graph/models/graph-model.ts b/src/plugins/graph/models/graph-model.ts index 8bed28d17d..9d0dc5f4e0 100644 --- a/src/plugins/graph/models/graph-model.ts +++ b/src/plugins/graph/models/graph-model.ts @@ -32,6 +32,8 @@ import { isSharedDataSet, SharedDataSet } from "../../../models/shared/shared-da import { DataConfigurationModel, RoleAttrIDPair } from "./data-configuration-model"; import { ISharedModelManager } from "../../../models/shared/shared-model-manager"; import { multiLegendParts } from "../components/legend/legend-registration"; +import { addAttributeToDataSet, DataSet } from "../../../models/data/data-set"; +import { getDocumentContentFromNode } from "../../../utilities/mst-utils"; export interface GraphProperties { axes: Record @@ -221,6 +223,12 @@ export const GraphModel = TileContentModel } return undefined; }, + /** + * Return a list of layers that can be edited. + */ + getEditableLayers() { + return self.layers.filter(l => l.editable); + }, /** * Find all tooltip-related attributes from all layers. * Returned as a list of { role, attribute } pairs. @@ -321,6 +329,42 @@ export const GraphModel = TileContentModel initialLayer.configureUnlinkedLayer(); } }, + /** + * Creates an "added by hand" dataset and attaches it as a layer to the graph. + * The layer is marked as editable so that the user can add and edit points. + */ + createEditableLayer() { + const smm = getSharedModelManager(self); + const doc = getDocumentContentFromNode(self); + if (doc && smm && smm.isReady) { + const datasetName = doc.getUniqueSharedModelName("Added by hand"); + const + xName = "X Variable", + yName = "Y Variable 1"; + const dataset = DataSet.create({ name: datasetName }); + const xAttr = addAttributeToDataSet(dataset, { name: xName }); + const yAttr = addAttributeToDataSet(dataset, { name: yName }); + const sharedDataSet = SharedDataSet.create({ dataSet: dataset }); + smm.addTileSharedModel(self, sharedDataSet, true); + + const metadata = SharedCaseMetadata.create(); + metadata.setData(dataset); + smm.addTileSharedModel(self, metadata); + + const layer = GraphLayerModel.create({ editable: true }); + self.layers.push(layer); + // Remove default layer if there was one + if (!self.layers[0].isLinked) { + self.layers.splice(0, 1); + } + + const dataConfiguration = DataConfigurationModel.create(); + layer.setDataConfiguration(dataConfiguration); + dataConfiguration.setDataset(dataset, metadata); + dataConfiguration.setAttributeForRole("x", { attributeID: xAttr.id, type: "numeric" }); + dataConfiguration.setAttributeForRole("y", { attributeID: yAttr.id, type: "numeric" }); + } + }, setXAttributeLabel(label: string) { self.xAttributeLabel = label; }, @@ -456,7 +500,7 @@ export const GraphModel = TileContentModel } }, setColorForIdWithoutUndo(id: string, colorIndex: number) { - withoutUndo(); + withoutUndo({unlessChildAction: true}); self.setColorForId(id, colorIndex); } })) diff --git a/src/plugins/graph/utilities/graph-utils.test.ts b/src/plugins/graph/utilities/graph-utils.test.ts index d4f41a1535..4952b7fe30 100644 --- a/src/plugins/graph/utilities/graph-utils.test.ts +++ b/src/plugins/graph/utilities/graph-utils.test.ts @@ -105,6 +105,7 @@ describe("updateGraphContentWithNewSharedModelIds", () => { "lockAxes": false, "plotType": "scatterPlot", "layers": [{ "id": "LAYRLybDWmk6IEI-", + "editable": false, "config": { "id": "DCON3uYgNhsq_4tk", "dataset": "UL53mvolYBJ5hIVr", "metadata": "7U0DJ-WxB83noPMK", "primaryRole": "x", @@ -138,7 +139,7 @@ describe("updateGraphContentWithNewSharedModelIds", () => { expect(result).not.toContain("CTZ8N5wGpbsgFPDr"); expect(result).not.toContain("t_Yigae_ENpSAaNJ"); // eslint-disable-next-line max-len - expect(result).toMatchInlineSnapshot(`"{\\"type\\":\\"Graph\\",\\"adornments\\":[{\\"id\\":\\"ADRNxHvLKiH_ntmG\\",\\"type\\":\\"Connecting Lines\\",\\"isVisible\\":true}],\\"axes\\":{\\"bottom\\":{\\"type\\":\\"numeric\\",\\"place\\":\\"bottom\\",\\"scale\\":\\"linear\\",\\"min\\":-4.5,\\"max\\":7.5},\\"left\\":{\\"type\\":\\"numeric\\",\\"place\\":\\"left\\",\\"scale\\":\\"linear\\",\\"min\\":-3.5,\\"max\\":8.5}},\\"lockAxes\\":false,\\"plotType\\":\\"scatterPlot\\",\\"layers\\":[{\\"id\\":\\"LAYRLybDWmk6IEI-\\",\\"config\\":{\\"id\\":\\"DCON3uYgNhsq_4tk\\",\\"dataset\\":\\"dset1\\",\\"metadata\\":\\"7U0DJ-WxB83noPMK\\",\\"primaryRole\\":\\"x\\",\\"_attributeDescriptions\\":{\\"x\\":{\\"type\\":\\"numeric\\",\\"attributeID\\":\\"att1\\"}},\\"_yAttributeDescriptions\\":[{\\"type\\":\\"numeric\\",\\"attributeID\\":\\"att2\\"}]}}],\\"_idColors\\":{\\"att2\\":0},\\"_pointColors\\":[\\"#E6805B\\"],\\"_pointStrokeColor\\":\\"#FFFFFF\\",\\"pointStrokeSameAsFill\\":false,\\"pointSizeMultiplier\\":1,\\"plotBackgroundColor\\":\\"#FFFFFF\\",\\"isTransparent\\":false,\\"plotBackgroundImageID\\":\\"\\",\\"showParentToggles\\":false,\\"showMeasuresForSelection\\":false,\\"xAttributeLabel\\":\\"time\\",\\"yAttributeLabel\\":\\"Signal\\"}"`); + expect(result).toMatchInlineSnapshot(`"{\\"type\\":\\"Graph\\",\\"adornments\\":[{\\"id\\":\\"ADRNxHvLKiH_ntmG\\",\\"type\\":\\"Connecting Lines\\",\\"isVisible\\":true}],\\"axes\\":{\\"bottom\\":{\\"type\\":\\"numeric\\",\\"place\\":\\"bottom\\",\\"scale\\":\\"linear\\",\\"min\\":-4.5,\\"max\\":7.5},\\"left\\":{\\"type\\":\\"numeric\\",\\"place\\":\\"left\\",\\"scale\\":\\"linear\\",\\"min\\":-3.5,\\"max\\":8.5}},\\"lockAxes\\":false,\\"plotType\\":\\"scatterPlot\\",\\"layers\\":[{\\"id\\":\\"LAYRLybDWmk6IEI-\\",\\"editable\\":false,\\"config\\":{\\"id\\":\\"DCON3uYgNhsq_4tk\\",\\"dataset\\":\\"dset1\\",\\"metadata\\":\\"7U0DJ-WxB83noPMK\\",\\"primaryRole\\":\\"x\\",\\"_attributeDescriptions\\":{\\"x\\":{\\"type\\":\\"numeric\\",\\"attributeID\\":\\"att1\\"}},\\"_yAttributeDescriptions\\":[{\\"type\\":\\"numeric\\",\\"attributeID\\":\\"att2\\"}]}}],\\"_idColors\\":{\\"att2\\":0},\\"_pointColors\\":[\\"#E6805B\\"],\\"_pointStrokeColor\\":\\"#FFFFFF\\",\\"pointStrokeSameAsFill\\":false,\\"pointSizeMultiplier\\":1,\\"plotBackgroundColor\\":\\"#FFFFFF\\",\\"isTransparent\\":false,\\"plotBackgroundImageID\\":\\"\\",\\"showParentToggles\\":false,\\"showMeasuresForSelection\\":false,\\"xAttributeLabel\\":\\"time\\",\\"yAttributeLabel\\":\\"Signal\\"}"`); }); }); diff --git a/src/public/demo/units/qa-config-subtabs/content.json b/src/public/demo/units/qa-config-subtabs/content.json index bc1c48bc2e..5694d5ad91 100644 --- a/src/public/demo/units/qa-config-subtabs/content.json +++ b/src/public/demo/units/qa-config-subtabs/content.json @@ -38,6 +38,7 @@ "connectPointsByDefault": true, "tools": [ "link-tile-multiple", + "add-points-by-hand", "fit-all", "toggle-lock" ] diff --git a/src/public/demo/units/qa/content.json b/src/public/demo/units/qa/content.json index aab3c3d309..975f93ac20 100644 --- a/src/public/demo/units/qa/content.json +++ b/src/public/demo/units/qa/content.json @@ -57,6 +57,7 @@ "connectPointsByDefault": true, "tools": [ "link-tile-multiple", + "add-points-by-hand", "fit-all", "toggle-lock" ]