diff --git a/cypress/e2e/functional/tile_tests/bar_graph_tile_spec.js b/cypress/e2e/functional/tile_tests/bar_graph_tile_spec.js index f2abdbcd41..57a15d2fe2 100644 --- a/cypress/e2e/functional/tile_tests/bar_graph_tile_spec.js +++ b/cypress/e2e/functional/tile_tests/bar_graph_tile_spec.js @@ -1,55 +1,338 @@ import ClueCanvas from '../../../support/elements/common/cCanvas'; import Canvas from '../../../support/elements/common/Canvas'; import BarGraphTile from '../../../support/elements/tile/BarGraphTile'; +import TableToolTile + from '../../../support/elements/tile/TableToolTile'; +import { LogEventName } from "../../../../src/lib/logger-types"; + let clueCanvas = new ClueCanvas, - barGraph = new BarGraphTile; + barGraph = new BarGraphTile, + tableTile = new TableToolTile; // eslint-disable-next-line unused-imports/no-unused-vars const canvas = new Canvas; +const workspaces = ['.primary-workspace', '.read-only-local-workspace', '.read-only-remote-workspace']; + +function textMatchesList(selector, expected) { + selector.should('have.length', expected.length); + selector.each(($el, index) => { + cy.wrap($el).invoke('text').then(text => cy.wrap(text).should('eq', expected[index])); + }); +} + function beforeTest() { - const queryParams = `${Cypress.config("qaUnitStudent5")}`; - cy.visit(queryParams); - cy.waitForLoad(); - cy.showOnlyDocumentWorkspace(); + const url = "/editor/?appMode=qa&unit=./demo/units/qa/content.json"; + cy.visit(url); + cy.window().then(win => { + cy.stub(win.ccLogger, "log").as("log"); + }); } context('Bar Graph Tile', function () { - it('Can create tile', function () { + it('Basic tile operations', function () { beforeTest(); clueCanvas.addTile('bargraph'); - barGraph.getTiles().should('have.length', 1); - barGraph.getTile() - .should('be.visible') - .and('have.class', 'bar-graph-tile') - .and('not.have.class', 'read-only'); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 1); + barGraph.getTile(workspace, 0) + .should('be.visible') + .and('have.class', 'bar-graph-tile'); + barGraph.getTileTitle(workspace).should("be.visible").and('have.text', 'Bar Graph 1'); + barGraph.getYAxisLabel(workspace).should('have.text', 'Counts'); + barGraph.getXAxisPulldownButton(workspace).should('have.text', 'Categories'); + } + barGraph.getTile(workspaces[0]).should('not.have.class', 'readonly'); + barGraph.getTile(workspaces[1]).should('have.class', 'readonly'); + barGraph.getTile(workspaces[2]).should('have.class', 'readonly'); - barGraph.getTileTitle().should("be.visible").and('have.text', 'Bar Graph 1'); - barGraph.getYAxisLabel().should('have.text', 'Counts'); - barGraph.getXAxisPulldownButton(0).should('have.text', 'date'); - }); + cy.get("@log") + .should("have.been.been.calledWith", LogEventName.CREATE_TILE, Cypress.sinon.match.object) + .its("firstCall.args.1").should("deep.include", { objectType: "BarGraph" }); - it('Can edit Y axis label', function () { - beforeTest(); - clueCanvas.addTile('bargraph'); - barGraph.getYAxisLabel().should('have.text', 'Counts'); + // Undo/redo tile creation + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 0); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 1); + } + + cy.log('Change Y axis label'); barGraph.getYAxisLabelEditor().should('not.exist'); barGraph.getYAxisLabelButton().click(); barGraph.getYAxisLabelEditor().should('be.visible').type(' of something{enter}'); barGraph.getYAxisLabelEditor().should('not.exist'); + for (const workspace of workspaces) { + barGraph.getYAxisLabel(workspace).should('have.text', 'Counts of something'); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.BARGRAPH_TOOL_CHANGE); + cy.get("@log").its("lastCall.args.1").should("deep.include", { operation: "setYAxisLabel", text: "Counts of something" }); + + // Undo/redo label change + clueCanvas.getUndoTool().click(); + barGraph.getYAxisLabel().should('have.text', 'Counts'); + clueCanvas.getRedoTool().click(); + barGraph.getYAxisLabel().should('have.text', 'Counts of something'); + + // ESC key should cancel the edit + barGraph.getYAxisLabelButton().click(); + barGraph.getYAxisLabelEditor().should('be.visible').type(' abandon this{esc}'); + barGraph.getYAxisLabelEditor().should('not.exist'); barGraph.getYAxisLabel().should('have.text', 'Counts of something'); + + // Should not be able to change Y axis label in read-only views + barGraph.getYAxisLabelButton(workspaces[1]).click(); + barGraph.getYAxisLabelEditor(workspaces[1]).should('not.exist'); + barGraph.getYAxisLabelButton(workspaces[2]).click(); + barGraph.getYAxisLabelEditor(workspaces[2]).should('not.exist'); + + cy.log('Duplicate tile'); + clueCanvas.getDuplicateTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 2); + barGraph.getTile(workspace, 0) + .should('be.visible') + .and('have.class', 'bar-graph-tile'); + barGraph.getTileTitle(workspace, 0).should("be.visible").and('have.text', 'Bar Graph 1'); + barGraph.getYAxisLabel(workspace, 0).should('have.text', 'Counts of something'); + barGraph.getXAxisPulldownButton(workspace, 0).should('have.text', 'Categories'); + + barGraph.getTile(workspace, 1) + .should('be.visible') + .and('have.class', 'bar-graph-tile'); + barGraph.getTileTitle(workspace, 1).should("be.visible").and('have.text', 'Bar Graph 2'); + barGraph.getYAxisLabel(workspace, 1).should('have.text', 'Counts of something'); + barGraph.getXAxisPulldownButton(workspace, 1).should('have.text', 'Categories'); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.COPY_TILE); + cy.get("@log").its("lastCall.args.1").should("deep.include", { objectType: "BarGraph" }); + + // Undo/redo tile duplication + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 1); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 2); + } + + cy.log('Delete tile'); + clueCanvas.deleteTile('bargraph'); + clueCanvas.deleteTile('bargraph'); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 0); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.DELETE_TILE); + cy.get("@log").its("lastCall.args.1").should("deep.include", { objectType: "BarGraph" }); + + // Undo/redo tile deletion + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 1); + } + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 2); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 1); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getTiles(workspace).should('have.length', 0); + } }); - it('Can change primary category', function () { + it('Can link data ', function () { beforeTest(); + clueCanvas.addTile('bargraph'); - barGraph.getXAxisPulldown().should('have.text', 'date'); + for (const workspace of workspaces) { + barGraph.getYAxisLabel(workspace).should('have.text', 'Counts'); + barGraph.getXAxisPulldown(workspace).should('have.text', 'Categories'); + barGraph.getYAxisTickLabel(workspace).should('not.exist'); + barGraph.getXAxisTickLabel(workspace).should('not.exist'); + barGraph.getLegendArea(workspace).should('not.exist'); + barGraph.getBar(workspace).should('not.exist'); + } + + // Table dataset for testing: + // 4 instances of X / Y / Z + // 2 instances of XX / Y / Z + // 1 instance of X / YY / Z + clueCanvas.addTile('table'); + tableTile.fillTable(tableTile.getTableTile(), [ + ['X', 'Y', 'Z'], + ['XX', 'Y', 'Z'], + ['X', 'YY', 'Z'], + ['X', 'Y', 'Z'], + ['XX', 'Y', 'Z'], + ['X', 'Y', 'Z'], + ['X', 'Y', 'Z'], + ]); + + cy.log('Link bar graph'); + barGraph.getTile().click(); + clueCanvas.clickToolbarButton('bargraph', 'link-tile'); + cy.get('select').select('Table Data 1'); + cy.get('.modal-button').contains("Graph It!").click(); + + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'x'); + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['X', 'XX']); + textMatchesList(barGraph.getYAxisTickLabel(workspace), ['0', '1', '2', '3', '4', '5']); + barGraph.getBar(workspace).should('have.length', 2); + barGraph.getDatasetLabel(workspace).should('have.text', 'Table Data 1'); + barGraph.getSortByMenuButton(workspace).should('have.text', 'None'); + barGraph.getSecondaryValueName(workspace).should('have.length', 1).and('have.text', 'x'); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.TILE_LINK); + cy.get("@log").its("lastCall.args.1").should("nested.include", { "sourceTile.type": "BarGraph", "sharedModel.type": "SharedDataSet" }); + + // Undo/redo linking + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'Categories'); + barGraph.getLegendArea(workspace).should('not.exist'); + barGraph.getBar(workspace).should('not.exist'); + } + + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getDatasetLabel(workspace).should('have.text', 'Table Data 1'); + barGraph.getXAxisPulldown(workspace).should('have.text', 'x'); + barGraph.getBar(workspace).should('have.length', 2); + } + + cy.log('Legend should move to bottom when tile is narrow'); + barGraph.getTileContent().should('have.class', 'horizontal').and('not.have.class', 'vertical'); + clueCanvas.addTileByDrag('table', 'right'); + clueCanvas.addTileByDrag('table', 'right'); + for (const workspace of workspaces) { + barGraph.getTileContent(workspace).should('have.class', 'vertical').and('not.have.class', 'horizontal'); + } + clueCanvas.getUndoTool().click(); // undo add table + clueCanvas.getUndoTool().click(); // undo add table + for (const workspace of workspaces) { + tableTile.getTableTile(workspace).should('have.length', 1); + barGraph.getTileContent(workspace).should('have.class', 'horizontal').and('not.have.class', 'vertical'); + } + + cy.log('Change Sort By'); + barGraph.getSortByMenuButton().should('have.text', 'None'); + + // Cannot change sort by in read-only views + for (const workspace of workspaces.slice(1)) { + barGraph.getSortByMenuButton(workspace).click(); + barGraph.getChakraMenuItem(workspace).should('have.length', 3); + barGraph.getChakraMenuItem(workspace).eq(1).should('have.text', 'y'); // menu exists + barGraph.getChakraMenuItem(workspace).should('be.disabled'); // all options disabled + barGraph.getSortByMenuButton(workspace).click(); // close menu + } + + barGraph.getSortByMenuButton().click(); + barGraph.getChakraMenuItem().should('have.length', 3); + barGraph.getChakraMenuItem().eq(1).should('have.text', 'y').click(); + for (const workspace of workspaces) { + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['X', 'XX']); + textMatchesList(barGraph.getYAxisTickLabel(workspace), ['0', '1', '2', '3', '4', '5']); + barGraph.getBar(workspace).should('have.length', 3); + barGraph.getDatasetLabel(workspace).should('have.text', 'Table Data 1'); + barGraph.getSortByMenuButton(workspace).should('have.text', 'y'); + textMatchesList(barGraph.getSecondaryValueName(workspace), ['Y', 'YY']); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.BARGRAPH_TOOL_CHANGE); + cy.get("@log").its("lastCall.args.1").should("deep.include", { operation: "setSecondaryAttribute" }); + + // Undo-redo sort by + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getSortByMenuButton(workspace).should('have.text', 'None'); + barGraph.getBar(workspace).should('have.length', 2); + barGraph.getSecondaryValueName(workspace).should('have.text', 'x'); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getSortByMenuButton(workspace).should('have.text', 'y'); + textMatchesList(barGraph.getSecondaryValueName(workspace), ['Y', 'YY']); + barGraph.getBar(workspace).should('have.length', 3); + } + + cy.log('Change Category'); + + // Cannot change category in read-only views + for (const workspace of workspaces.slice(1)) { + barGraph.getXAxisPulldownButton(workspace).click(); + barGraph.getChakraMenuItem(workspace).should('have.length', 3).and('be.disabled'); + barGraph.getXAxisPulldownButton(workspace).click(); // close menu + } + barGraph.getXAxisPulldownButton().click(); - barGraph.getXAxisPulldownMenuItem().eq(1).click(); - barGraph.getXAxisPulldown().should('have.text', 'location'); + barGraph.getChakraMenuItem().should('have.length', 3); + barGraph.getChakraMenuItem().eq(1).should('have.text', 'y').click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'y'); + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['Y', 'YY']); + textMatchesList(barGraph.getYAxisTickLabel(workspace), ['0', '2', '4', '6', '8', '10']); // there are 6 Ys in this view so scale expands. + barGraph.getBar(workspace).should('have.length', 2); + barGraph.getDatasetLabel(workspace).should('have.text', 'Table Data 1'); + barGraph.getSortByMenuButton(workspace).should('have.text', 'None'); + barGraph.getSecondaryValueName(workspace).should('have.length', 1).and('have.text', 'y'); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.BARGRAPH_TOOL_CHANGE); + cy.get("@log").its("lastCall.args.1").should("deep.include", { operation: "setPrimaryAttribute" }); + + // Undo-redo category change + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'x'); + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['X', 'XX']); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'y'); + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['Y', 'YY']); + } + + cy.log('Unlink data'); + barGraph.getDatasetUnlinkButton().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'Categories'); + barGraph.getYAxisTickLabel(workspace).should('not.exist'); + barGraph.getXAxisTickLabel(workspace).should('not.exist'); + barGraph.getLegendArea(workspace).should('not.exist'); + barGraph.getBar(workspace).should('not.exist'); + } + + cy.get("@log").its("lastCall.args.0").should("equal", LogEventName.TILE_UNLINK); + cy.get("@log").its("lastCall.args.1").should("nested.include", { "sourceTile.type": "BarGraph", "sharedModel.type": "SharedDataSet" }); + + // Undo-redo unlink + clueCanvas.getUndoTool().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown().should('have.text', 'y'); + textMatchesList(barGraph.getXAxisTickLabel(workspace), ['Y', 'YY']); + barGraph.getBar(workspace).should('have.length', 2); + } + clueCanvas.getRedoTool().click(); + for (const workspace of workspaces) { + barGraph.getXAxisPulldown(workspace).should('have.text', 'Categories'); + barGraph.getBar(workspace).should('not.exist'); + } }); }); 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 ac69f7f775..3f3dece060 100644 --- a/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js +++ b/cypress/e2e/functional/tile_tests/xy_plot_tool_spec.js @@ -19,27 +19,6 @@ const queryParamsPlotVariables = `${Cypress.config("qaNoGroupShareUnitStudent5") const problemDoc = '1.1 Unit Toolbar Configuration'; -// Construct and fill in a table tile with the given data (a list of lists) -function buildTable(data) { - // at least two cols, or as many as the longest row in the data array - const cols = Math.max(2, ...data.map(row => row.length)); - clueCanvas.addTile('table'); - tableToolTile.getTableTile().last().should('be.visible'); - tableToolTile.getTableTile().last().within((tile) => { - // tile will start with two columns; make more if desired - for (let i=2; i row.length)); + $tile.within((tile) => { + // tile will start with two columns; make more if desired + for (let i=2; i
Read Only Local
- + } { showRemoteReadOnly && <>
Read Only Remote (emulated)
- + } @@ -243,7 +243,7 @@ export const DocEditorApp = observer(function DocEditorApp() { ); }); -const ReadonlyCanvas = ({document}:{document: DocumentModelType}) => { +const ReadonlyCanvas = ({document, className}:{document: DocumentModelType, className: string}) => { const readOnlyScale = 0.5; const scaledStyle = { position: "absolute", @@ -254,7 +254,7 @@ const ReadonlyCanvas = ({document}:{document: DocumentModelType}) => { } as const; return ( -
+
{ + const appConfig = useAppConfig(); const {handleRequestTileLink, handleRequestTileUnlink} = actionHandlers || {}; const sharedModelManager = getSharedModelManager(model); const sharedModels: SharedModelType[] = []; @@ -55,10 +57,14 @@ export const useProviderTileLinking = ({ const linkTile = useCallback((sharedModel: SharedModelType) => { if (!readOnly && sharedModelManager?.isReady) { - // TODO: this is temporary while we are working on getting Graph to work with multiple datasets - // Once multiple datasets are fully implemented, we should look at the "consumesMultipleDataSets" - // setting for the tile type; but for now graph has to allow multiples while not having that be the default. - if (!allowMultipleGraphDatasets && isGraphModel(model.content)) { + // Depending on the unit configuration, graphs sometimes allow multiple datasets and sometimes not. + // Other tiles register their ability to consume multiple datasets as part of their content info. + const allowsMultiple = isGraphModel(model.content) + ? allowMultipleGraphDatasets + : getTileContentInfo(model.content.type)?.consumesMultipleDataSets?.(appConfig); + + if (!allowsMultiple) { + // Remove any existing shared models before adding the new one for (const shared of sharedModelManager.getTileSharedModels(model.content)) { sharedModelManager.removeTileSharedModel(model.content, shared); } @@ -68,7 +74,7 @@ export const useProviderTileLinking = ({ logSharedModelDocEvent(LogEventName.TILE_LINK, model, sharedTiles, sharedModel); } - }, [readOnly, sharedModelManager, model, allowMultipleGraphDatasets]); + }, [appConfig, readOnly, sharedModelManager, model, allowMultipleGraphDatasets]); const unlinkTile = useCallback((sharedModel: SharedModelType) => { if (!readOnly && sharedModelManager?.isReady) { diff --git a/src/lib/logger-types.ts b/src/lib/logger-types.ts index a566aa6b7c..a0d77d3a06 100644 --- a/src/lib/logger-types.ts +++ b/src/lib/logger-types.ts @@ -32,6 +32,7 @@ export enum LogEventName { HIDE_SOLUTIONS, SHOW_SOLUTIONS, + BARGRAPH_TOOL_CHANGE, GEOMETRY_TOOL_CHANGE, DRAWING_TOOL_CHANGE, TABLE_TOOL_CHANGE, diff --git a/src/plugins/bar-graph/bar-graph-chart.tsx b/src/plugins/bar-graph/bar-graph-chart.tsx deleted file mode 100644 index 42e15c71bd..0000000000 --- a/src/plugins/bar-graph/bar-graph-chart.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { AxisBottom, AxisLeft } from "@visx/axis"; -import { GridRows } from "@visx/grid"; -import { Group } from "@visx/group"; -import { scaleBand, scaleLinear } from "@visx/scale"; -import { Bar, BarGroup } from "@visx/shape"; -import { isNumber } from "lodash"; -import { observer } from "mobx-react"; -import React, { useMemo } from "react"; -import { clueDataColorInfo } from "../../utilities/color-utils"; -import { useBarGraphModelContext } from "./bar-graph-content-context"; -import { CategoryPulldown } from "./category-pulldown"; -import EditableAxisLabel from "./editable-axis-label"; - -const margin = { - top: 60, - bottom: 60, - left: 80, - right: 80, -}; - -const demoCases: Record[] = [ - { date: '6/23/24', location: 'deck' }, - { date: '6/23/24', location: 'porch' }, - { date: '6/23/24', location: 'tree' }, - { date: '6/24/24', location: 'porch' }, - { date: '6/24/24', location: 'porch' }, - { date: '6/25/24', location: 'backyard' }, - { date: '6/25/24', location: 'deck' }, - { date: '6/25/24', location: 'deck' }, - { date: '6/25/24', location: 'deck' }, - { date: '6/25/24', location: 'tree' }, - { date: '6/26/24', location: 'backyard' }, - { date: '6/26/24', location: 'deck' }, - { date: '6/26/24', location: 'deck' }, - { date: '6/26/24', location: 'porch' }, - { date: '6/26/24', location: 'tree' } -]; - -function roundTo5(n: number): number { - return Math.ceil(n/5)*5; -} - -function barColor(n: number) { - return clueDataColorInfo[n % clueDataColorInfo.length].color; -} - -interface IBarGraphChartProps { - width: number; - height: number; -} - -// TODO rotate labels if needed -// angle: -45, textAnchor: 'end' -// https://github.com/airbnb/visx/discussions/1494 - - -export const BarGraphChart = observer(function BarGraphChart({ width, height }: IBarGraphChartProps) { - - const model = useBarGraphModelContext(); - const primary = model?.primaryAttribute || "date"; - const secondary = model?.secondaryAttribute || "location"; - - const xMax = width - margin.left - margin.right; - const yMax = height - margin.top - margin.bottom; - - function setDemoCategory(catname: string) { - if (catname === "date") { - model?.setPrimaryAttribute("date"); - model?.setSecondaryAttribute("location"); - } else{ - model?.setPrimaryAttribute("location"); - model?.setSecondaryAttribute("date"); - } - } - - // Count cases and make the data array - const data = useMemo( - () => demoCases.reduce((acc, row) => { - const cat = primary in row ? row[primary] : ""; - const subCat = row[secondary] || ""; - const index = acc.findIndex(r => r[primary] === cat); - if (index >= 0) { - const cur = acc[index][subCat]; - acc[index][subCat] = (isNumber(cur) ? cur : 0) + 1; - } else { - const newRow = { [primary]: cat, [subCat]: 1 }; - acc.push(newRow); - } - return acc; - }, [] as { [key: string]: number|string }[]), - [primary, secondary]); - - const primaryKeys: string[] - = useMemo(() => data.map(d => d[primary] as string), - [data, primary]); - const secondaryKeys: string[] - = useMemo(() => Array.from(new Set(data.flatMap(d => Object.keys(d)).filter(k => k !== primary))), - [data, primary]); - - // find the maximum data value - const maxValue = data.reduce((acc, row) => { - const rowValues = Object.values(row).slice(1) as (string | number)[]; - const maxInRow = Math.max(...rowValues.map(v => isNumber(v) ? v : 0)); - return Math.max(maxInRow, acc); - }, 0); - - const primaryScale = useMemo( - () => - scaleBand({ - domain: primaryKeys, - padding: 0.2, - range: [0, xMax]}), - [xMax, primaryKeys]); - - const secondaryScale = useMemo( - () => - scaleBand({ - domain: secondaryKeys, - padding: 0.2, - range: [0, primaryScale.bandwidth()]}), - [primaryScale, secondaryKeys]); - - const countScale = useMemo( - () => - scaleLinear({ - domain: [0, roundTo5(maxValue)], - range: [yMax, 0], - }), - [yMax, maxValue]); - - if (xMax <= 0 || yMax <= 0) return Too small; - - const ticks = Math.min(4, Math.floor(yMax/40)); // leave generous vertical space (>=40 px) between ticks - const labelWidth = (xMax/primaryKeys.length)-10; // setting width will wrap lines in labels when needed - - return ( - - - - - Number(value).toFixed(0)} - /> - barColor(i)} - className="bar" - keys={secondaryKeys} - height={yMax} - x0={(d) => d[primary] as string} - x0Scale={primaryScale} - x1Scale={secondaryScale} - yScale={countScale} - > - {(barGroups) => - - {barGroups.map((barGroup) => ( - - {barGroup.bars.map((bar) => { - if(!bar.value) return null; - return ; - })} - - ))} - - } - - - model?.setYAxisLabel(text)} - /> - - - ); -}); diff --git a/src/plugins/bar-graph/bar-graph-content.test.ts b/src/plugins/bar-graph/bar-graph-content.test.ts index 1b5a7029c4..565a7f56fc 100644 --- a/src/plugins/bar-graph/bar-graph-content.test.ts +++ b/src/plugins/bar-graph/bar-graph-content.test.ts @@ -1,14 +1,67 @@ +import { getSnapshot } from "mobx-state-tree"; +import { addAttributeToDataSet, addCasesToDataSet, DataSet } from "../../models/data/data-set"; +import { ICaseCreation } from "../../models/data/data-set-types"; +import { SharedDataSet, SharedDataSetType } from "../../models/shared/shared-data-set"; import { defaultBarGraphContent, BarGraphContentModel } from "./bar-graph-content"; +const mockCases: ICaseCreation[] = [ + { species: "cat", location: "yard" }, + { species: "cat", location: "yard" }, + { species: "owl", location: "yard" }, + { species: "owl", location: "forest" } +]; + +function sharedEmptyDataSet() { + const emptyDataSet = DataSet.create(); + addAttributeToDataSet(emptyDataSet, { id: "att-s", name: "species" }); + addAttributeToDataSet(emptyDataSet, { id: "att-l", name: "location" }); + return SharedDataSet.create({ dataSet: emptyDataSet }); +} + +function sharedSampleDataSet() { + const sampleDataSet = DataSet.create(); + addAttributeToDataSet(sampleDataSet, { id: "att-s", name: "species" }); + addAttributeToDataSet(sampleDataSet, { id: "att-l", name: "location" }); + addCasesToDataSet(sampleDataSet, mockCases); + return SharedDataSet.create({ dataSet: sampleDataSet }); +} + +// This is a testable version of the BarGraphContentModel that doesn't rely on the shared model manager +// It just lets you set a SharedModel and returns that. +const TestingBarGraphContentModel = BarGraphContentModel + .volatile(() => ({ + storedSharedModel: undefined as SharedDataSetType | undefined + })) + .actions(self => ({ + setSharedModel(sharedModel: SharedDataSetType) { + self.storedSharedModel = sharedModel; + self.updateAfterSharedModelChanges(sharedModel); + } + })) + .views(self => ({ + get sharedModel() { + return self.storedSharedModel; + } + })); + describe("Bar Graph Content", () => { it("is a Bar Graph model", () => { const content = BarGraphContentModel.create(); expect(content.type).toBe("BarGraph"); }); - it("yAxisLabel has default content of 'Counts'", () => { + it("yAxisLabel has expected default content", () => { const content = defaultBarGraphContent(); expect(content.yAxisLabel).toBe("Counts"); + expect(getSnapshot(content)).toMatchInlineSnapshot(` +Object { + "dataSetId": undefined, + "primaryAttribute": undefined, + "secondaryAttribute": undefined, + "type": "BarGraph", + "yAxisLabel": "Counts", +} +`); }); it("is always user resizable", () => { @@ -36,4 +89,102 @@ describe("Bar Graph Content", () => { expect(content.secondaryAttribute).toBe("attrId"); }); + it("returns empty data array when there are no cases", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedEmptyDataSet()); + expect(content.dataArray).toEqual([]); + }); + + it("returns empty data array when there is no primary attribute", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute(undefined); + expect(content.dataArray).toEqual([]); + }); + + it("returns expected data array with primary attribute", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute("att-s"); + expect(content.dataArray).toEqual([ + { "att-s": "cat", "value": 2 }, + { "att-s": "owl","value": 2} + ]); + + content.setPrimaryAttribute("att-l"); + expect(content.dataArray).toEqual([ + { "att-l": "yard", "value": 3 }, + { "att-l": "forest", "value": 1 } + ]); + }); + + it("sets first dataset attribute as the primary attribute by default", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + expect(content.dataArray).toEqual([ + { "att-s": "cat", "value": 2 }, + { "att-s": "owl","value": 2} + ]); + }); + + it("returns expected data array with primary and secondary attributes", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute("att-s"); + content.setSecondaryAttribute("att-l"); + expect(content.dataArray).toEqual([ + { "att-s": "cat", "yard": 2 }, + { "att-s": "owl", "yard": 1, "forest": 1 } + ]); + }); + + it("fills in missing values with (no value)", () => { + const content = TestingBarGraphContentModel.create({ }); + const dataSet = sharedSampleDataSet(); + dataSet.dataSet?.attributes[1].setValue(3, undefined); // hide forest owl's location + content.setSharedModel(dataSet); + content.setPrimaryAttribute("att-s"); + content.setSecondaryAttribute("att-l"); + expect(content.dataArray).toEqual([ + { "att-s": "cat", "yard": 2 }, + { "att-s": "owl", "yard": 1, "(no value)": 1 } + ]); + + dataSet.dataSet?.attributes[0].setValue(3, undefined); // hide that owl entirely + expect(content.dataArray).toEqual([ + { "att-s": "cat", "yard": 2 }, + { "att-s": "owl", "yard": 1 }, + { "att-s": "(no value)", "(no value)": 1 } + ]); + + }); + + it("extracts primary keys", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute("att-s"); + expect(content.primaryKeys).toEqual(["cat", "owl"]); + }); + + it("extracts secondary keys", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute("att-s"); + content.setSecondaryAttribute("att-l"); + expect(content.secondaryKeys).toEqual(["yard", "forest"]); + }); + + it("calculates the maximum data value", () => { + const content = TestingBarGraphContentModel.create({ }); + content.setSharedModel(sharedSampleDataSet()); + content.setPrimaryAttribute("att-s"); + expect(content.maxDataValue).toBe(2); + + content.setPrimaryAttribute("att-l"); + expect(content.maxDataValue).toBe(3); + + content.setSecondaryAttribute("att-s"); + expect(content.maxDataValue).toBe(2); + }); + }); diff --git a/src/plugins/bar-graph/bar-graph-content.ts b/src/plugins/bar-graph/bar-graph-content.ts index fbe79b2649..60a1571984 100644 --- a/src/plugins/bar-graph/bar-graph-content.ts +++ b/src/plugins/bar-graph/bar-graph-content.ts @@ -1,6 +1,12 @@ import { types, Instance } from "mobx-state-tree"; +import { isNumber } from "lodash"; import { ITileContentModel, TileContentModel } from "../../models/tiles/tile-content"; import { kBarGraphTileType, kBarGraphContentType } from "./bar-graph-types"; +import { getSharedModelManager } from "../../models/tiles/tile-environment"; +import { SharedDataSet, SharedDataSetType } from "../../models/shared/shared-data-set"; +import { clueDataColorInfo } from "../../utilities/color-utils"; +import { displayValue } from "./bar-graph-utils"; +import { SharedModelType } from "../../models/shared/shared-model"; export function defaultBarGraphContent(): BarGraphContentModelType { return BarGraphContentModel.create({yAxisLabel: "Counts"}); @@ -11,29 +17,163 @@ export const BarGraphContentModel = TileContentModel .props({ type: types.optional(types.literal(kBarGraphTileType), kBarGraphTileType), yAxisLabel: "", + // ID of the dataset to which primaryAttribute and secondaryAttribute belong. + // The currently linked dataset is available from SharedModelManager, but we store the ID so + // that we can tell when it changes. + dataSetId: types.maybe(types.string), primaryAttribute: types.maybe(types.string), secondaryAttribute: types.maybe(types.string) }) .views(self => ({ + get sharedModel() { + const sharedModelManager = self.tileEnv?.sharedModelManager; + const firstSharedModel = sharedModelManager?.getTileSharedModelsByType(self, SharedDataSet)?.[0]; + if (!firstSharedModel) return undefined; + return firstSharedModel as SharedDataSetType; + }, get isUserResizable() { return true; } })) + .views(self => ({ + get cases() { + return self.sharedModel?.dataSet.cases; + } + })) + .views(self => ({ + /** + * Returns the dataset data in a format suitable for plotting. + * + * With a primary attribute "species" and no secondary attribute, this will be something like: + * ```json + * [ + * { species: "cat", value: 7 }, + * { species: "owl", value: 3 } + * ] + * ``` + * + * If there is a secondary attribute "location", this will be like: + * ```json + * [ + * { species: "cat", backyard: 5, street: 2, forest: 0 }, + * { species: "owl", backyard: 1, street: 0, forest: 2 } + * ] + * ``` + * Any empty values of attributes are replaced with "(no value)". + */ + get dataArray() { + const dataSet = self.sharedModel?.dataSet; + const primary = self.primaryAttribute; + const secondary = self.secondaryAttribute; + const cases = self.cases; + if (!dataSet || !primary || !cases) return []; + if (secondary) { + // Two-dimensionsal data + return cases.reduce((acc, caseID) => { + const cat = displayValue(dataSet.getStrValue(caseID.__id__, primary)); + const subCat = displayValue(dataSet.getStrValue(caseID.__id__, secondary)); + const index = acc.findIndex(r => r[primary] === cat); + if (index >= 0) { + const cur = acc[index][subCat]; + acc[index][subCat] = (isNumber(cur) ? cur : 0) + 1; + } else { + const newRow = { [primary]: cat, [subCat]: 1 }; + acc.push(newRow); + } + return acc; + }, [] as { [key: string]: number | string }[]); + } else { + // One-dimensional data + return cases.reduce((acc, caseID) => { + const cat = displayValue(dataSet.getStrValue(caseID.__id__, primary)); + const index = acc.findIndex(r => r[primary] === cat); + if (index >= 0) { + const cur = acc[index].value; + acc[index].value = isNumber(cur) ? cur + 1 : 1; + } else { + const newRow = { [primary]: cat, value: 1 }; + acc.push(newRow); + } + return acc; + }, [] as { [key: string]: number | string }[]); + } + } + })) + .views(self => ({ + get primaryKeys() { + const primary = self.primaryAttribute; + if (!primary) return []; + return self.dataArray.map(d => d[primary] as string); + }, + get secondaryKeys() { + const primary = self.primaryAttribute; + if (!primary) return []; + return Array.from(new Set(self.dataArray.flatMap(d => Object.keys(d)).filter(k => k !== primary))); + }, + get maxDataValue(): number { + return self.dataArray.reduce((acc, row) => { + const rowValues = Object.values(row).filter(v => isNumber(v)) as number[]; + const maxInRow = Math.max(...rowValues); + return Math.max(maxInRow, acc); + }, 0); + } + })) + .views(self => ({ + // TODO this should track colors in a way that can be edited later + getColorForSecondaryKey(key: string) { + let n = self.secondaryKeys.indexOf(key); + if (!n || n<0) n=0; + return clueDataColorInfo[n % clueDataColorInfo.length].color; + } + })) .actions(self => ({ setYAxisLabel(text: string) { self.yAxisLabel = text; }, - setPrimaryAttribute(attrId: string) { + setPrimaryAttribute(attrId: string|undefined) { self.primaryAttribute = attrId; + self.secondaryAttribute = undefined; }, - setSecondaryAttribute(attrId: string) { + setSecondaryAttribute(attrId: string|undefined) { self.secondaryAttribute = attrId; } + })) + .actions(self => ({ + unlinkDataSet() { + const smm = getSharedModelManager(self); + if (!smm || !smm.isReady) return; + const sharedDataSets = smm.getTileSharedModelsByType(self, SharedDataSet); + for (const sharedDataSet of sharedDataSets) { + smm.removeTileSharedModel(self, sharedDataSet); + } + }, + + updateAfterSharedModelChanges(sharedModel?: SharedModelType) { + // When new dataset is attached, store its ID and pick a primary attribute to display. + const dataSetId = self.sharedModel?.dataSet?.id; + if (self.dataSetId !== dataSetId) { + self.dataSetId = dataSetId; + self.setPrimaryAttribute(undefined); + self.setSecondaryAttribute(undefined); + if (dataSetId) { + const atts = self.sharedModel.dataSet.attributes; + if (atts.length > 0) { + self.setPrimaryAttribute(atts[0].id); + } + } + } + // Check if primary or secondary attribute has been deleted + if (self.primaryAttribute && !self.sharedModel?.dataSet.attrFromID(self.primaryAttribute)) { + self.setPrimaryAttribute(undefined); // this will also unset secondaryAttribute + } + if (self.secondaryAttribute && !self.sharedModel?.dataSet.attrFromID(self.secondaryAttribute)) { + self.setSecondaryAttribute(undefined); + } + } })); export interface BarGraphContentModelType extends Instance {} - export function isBarGraphModel(model?: ITileContentModel): model is BarGraphContentModelType { return model?.type === kBarGraphTileType; } diff --git a/src/plugins/bar-graph/bar-graph-registration.ts b/src/plugins/bar-graph/bar-graph-registration.ts index 3de8812d01..0ba5e2384b 100644 --- a/src/plugins/bar-graph/bar-graph-registration.ts +++ b/src/plugins/bar-graph/bar-graph-registration.ts @@ -3,6 +3,7 @@ import { registerTileContentInfo } from "../../models/tiles/tile-content-info"; import { kBarGraphTileType, kBarGraphDefaultHeight } from "./bar-graph-types"; import { BarGraphComponent } from "./bar-graph-tile"; import { defaultBarGraphContent, BarGraphContentModel } from "./bar-graph-content"; +import { updateBarGraphContentWithNewSharedModelIds } from "./bar-graph-utils"; import Icon from "./assets/bar-graph-icon.svg"; @@ -11,7 +12,8 @@ registerTileContentInfo({ displayName: "Bar Graph", modelClass: BarGraphContentModel, defaultContent: defaultBarGraphContent, - defaultHeight: kBarGraphDefaultHeight + defaultHeight: kBarGraphDefaultHeight, + updateContentWithNewSharedModelIds: updateBarGraphContentWithNewSharedModelIds }); registerTileComponentInfo({ diff --git a/src/plugins/bar-graph/bar-graph-tile.test.tsx b/src/plugins/bar-graph/bar-graph-tile.test.tsx deleted file mode 100644 index df552f64dd..0000000000 --- a/src/plugins/bar-graph/bar-graph-tile.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { render, getByText as globalGetByText } from "@testing-library/react"; - -import { ITileApi } from "../../components/tiles/tile-api"; -import { TileModel } from "../../models/tiles/tile-model"; -import { defaultBarGraphContent } from "./bar-graph-content"; -import { BarGraphComponent } from "./bar-graph-tile"; - -// The tile needs to be registered so the TileModel.create -// knows it is a supported tile type -import "./bar-graph-registration"; - -jest.mock("react-resize-detector", () => ({ - useResizeDetector: jest.fn(() => ({height: 200, width: 200, ref: null})) -})); - -jest.mock("./bar-graph-utils", () => ({ - getBBox: jest.fn(() => ({x: 0, y: 0, width: 500, height: 200})) -})); - - -describe("BarGraphComponent", () => { - const content = defaultBarGraphContent(); - const model = TileModel.create({content}); - - const defaultProps = { - tileElt: null, - context: "", - docId: "", - documentContent: null, - isUserResizable: true, - onResizeRow: (e: React.DragEvent): void => { - throw new Error("Function not implemented."); - }, - onSetCanAcceptDrop: (tileId?: string): void => { - throw new Error("Function not implemented."); - }, - onRequestRowHeight: (tileId: string, height?: number, deltaHeight?: number): void => { - throw new Error("Function not implemented."); - }, - onRegisterTileApi: (tileApi: ITileApi, facet?: string): void => { - throw new Error("Function not implemented."); - }, - onUnregisterTileApi: (facet?: string): void => { - throw new Error("Function not implemented."); - } - }; - - it("renders successfully", () => { - const {getByText, getByTestId} = - render(); - expect(getByText("Tile Title")).toBeInTheDocument(); - expect(getByTestId("bar-graph-content")).toBeInTheDocument(); - expect(globalGetByText(getByTestId("bar-graph-content"), "Counts")).toBeInTheDocument(); - expect(getByText("6/23/24")).toBeInTheDocument(); - }); - - it.skip("updates the text when the model changes", async () => { - const {getByTestId, findByText} = - render(); - expect(globalGetByText(getByTestId("bar-graph-content"), "Counts")).toBeInTheDocument(); - - content.setYAxisLabel("New Text"); - - expect(await findByText( "New Text")).toBeInTheDocument(); - }); - -}); diff --git a/src/plugins/bar-graph/bar-graph-tile.tsx b/src/plugins/bar-graph/bar-graph-tile.tsx index d307016faf..e8dc7bffc5 100644 --- a/src/plugins/bar-graph/bar-graph-tile.tsx +++ b/src/plugins/bar-graph/bar-graph-tile.tsx @@ -1,34 +1,80 @@ -import React from "react"; +import React, { useRef } from "react"; import classNames from "classnames"; import { observer } from "mobx-react"; import { useResizeDetector } from "react-resize-detector"; +import { ChartArea } from "./chart-area"; +import { LegendArea } from "./legend-area"; import { BasicEditableTileTitle } from "../../components/tiles/basic-editable-tile-title"; import { ITileProps } from "../../components/tiles/tile-component"; -import { BarGraphChart } from "./bar-graph-chart"; import { BarGraphModelContext } from "./bar-graph-content-context"; import { isBarGraphModel } from "./bar-graph-content"; +import { TileToolbar } from "../../components/toolbar/tile-toolbar"; import "./bar-graph.scss"; +import "./bar-graph-toolbar"; + +const legendWidth = 190; + export const BarGraphComponent: React.FC = observer((props: ITileProps) => { + const { model, readOnly, onRequestRowHeight } = props; + const content = isBarGraphModel(model.content) ? model.content : null; - const {height: resizeHeight, width: resizeWidth, ref} = useResizeDetector(); + const requestedHeight = useRef(undefined); - const { model, readOnly } = props; + const onResize = (width: number|undefined, height: number|undefined) => { + let desiredTileHeight; + if (height) { + if (legendBelow) { + const desiredLegendHeight = height; + desiredTileHeight = 300 + desiredLegendHeight; + } else { + const desiredLegendHeight = Math.max(height, 260); // Leave room for at least 5 rows per spec + desiredTileHeight = desiredLegendHeight + 66; + } + if (requestedHeight.current !== desiredTileHeight) { + requestedHeight.current = desiredTileHeight; + onRequestRowHeight(model.id, desiredTileHeight); + } + } + }; - const content = isBarGraphModel(model.content) ? model.content : null; + // We use two resize detectors to track the size of the container and the size of the legend area + const { height: containerHeight, width: containerWidth, ref: containerRef } = useResizeDetector(); + + const { height: legendHeight, ref: legendRef } = useResizeDetector({ + refreshMode: 'debounce', + refreshRate: 500, + skipOnMount: false, + onResize + }); + let svgWidth = 10, svgHeight = 10; + // Legend is on the right if the width is >= 450px, otherwise below + const legendBelow = containerWidth && containerWidth < 450; + if (containerWidth && containerHeight) { + if (legendBelow) { + const vertPadding = 18; + svgWidth = containerWidth; + svgHeight = containerHeight - vertPadding - (legendHeight || 0); + } else { + svgWidth = containerWidth - legendWidth; + svgHeight = containerHeight; + } + } return ( +
- + +
); diff --git a/src/plugins/bar-graph/bar-graph-toolbar.tsx b/src/plugins/bar-graph/bar-graph-toolbar.tsx new file mode 100644 index 0000000000..ceab59fb5a --- /dev/null +++ b/src/plugins/bar-graph/bar-graph-toolbar.tsx @@ -0,0 +1,40 @@ +import React, { useContext } from "react"; +import { TileModelContext } from "../../components/tiles/tile-api"; +import { TileToolbarButton } from "../../components/toolbar/tile-toolbar-button"; +import { IToolbarButtonComponentProps, registerTileToolbarButtons } + from "../../components/toolbar/toolbar-button-manager"; +import { useProviderTileLinking } from "../../hooks/use-provider-tile-linking"; + +import LinkTableIcon from "../../clue/assets/icons/geometry/link-table-icon.svg"; + +function LinkTileButton({name}: IToolbarButtonComponentProps) { + + const model = useContext(TileModelContext)!; + + const { isLinkEnabled, showLinkTileDialog } + = useProviderTileLinking({ model, sharedModelTypes: [ "SharedDataSet" ] }); + + const handleLinkTileButtonClick = (e: React.MouseEvent) => { + isLinkEnabled && showLinkTileDialog(); + e.stopPropagation(); + }; + + return ( + + + + ); +} + +registerTileToolbarButtons("bargraph", +[ + { + name: 'link-tile', + component: LinkTileButton + } +]); diff --git a/src/plugins/bar-graph/bar-graph-utils.ts b/src/plugins/bar-graph/bar-graph-utils.ts index 991175e7b3..e5a1d86810 100644 --- a/src/plugins/bar-graph/bar-graph-utils.ts +++ b/src/plugins/bar-graph/bar-graph-utils.ts @@ -1,5 +1,55 @@ +import { SnapshotOut } from "mobx-state-tree"; +import { LogEventName } from "../../lib/logger-types"; +import { SharedModelEntrySnapshotType } from "../../models/document/shared-model-entry"; +import { replaceJsonStringsWithUpdatedIds, UpdatedSharedDataSetIds } from "../../models/shared/shared-data-set"; +import { logTileChangeEvent } from "../../models/tiles/log/log-tile-change-event"; +import { getTileIdFromContent } from "../../models/tiles/tile-model"; +import { BarGraphContentModel, BarGraphContentModelType } from "./bar-graph-content"; -// Just wraps the native getBBox method to make it mockable in tests +const kMissingValueString = "(no value)"; + +// Substitute "(no value)" for missing data +export function displayValue(attrValue: string | undefined): string { + return attrValue ? attrValue : kMissingValueString; +} + +// true if the string matches the pattern that we use for missing data +export function isMissingData(display: string): boolean { + return display === kMissingValueString; +} + +// Wraps the native getBBox method to make it mockable in tests export function getBBox(element: SVGGraphicsElement): DOMRect { return element.getBBox(); } + +// Round a number up to the next multiple of 5. +export function roundTo5(n: number): number { + return Math.max(5, Math.ceil(n/5)*5); +} + +export function updateBarGraphContentWithNewSharedModelIds( + content: SnapshotOut, + sharedDataSetEntries: SharedModelEntrySnapshotType[], + updatedSharedModelMap: Record +) { + return replaceJsonStringsWithUpdatedIds(content, '"', ...Object.values(updatedSharedModelMap)); +} + +// Define types here to document all possible values that this tile logs +type LoggableOperation = "setPrimaryAttribute" | "setSecondaryAttribute" | "setYAxisLabel"; +type LoggableChange = { + attributeId?: string; + text?: string; +}; + +export function logBarGraphEvent( + model: BarGraphContentModelType, operation: LoggableOperation, change: LoggableChange) { + const tileId = getTileIdFromContent(model) || ""; + + logTileChangeEvent(LogEventName.BARGRAPH_TOOL_CHANGE, { + tileId, + operation, + change + }); +} diff --git a/src/plugins/bar-graph/bar-graph.scss b/src/plugins/bar-graph/bar-graph.scss index 20fc30859e..862b76a1c5 100644 --- a/src/plugins/bar-graph/bar-graph.scss +++ b/src/plugins/bar-graph/bar-graph.scss @@ -3,15 +3,13 @@ .bar-graph-content { width: 100%; height: 100%; + padding: 44px 0 12px 0; display: flex; flex-direction: row; - flex-wrap: nowrap; align-items: center; overflow: auto; - svg { - width: 100%; - height: 100%; + svg.bar-graph-svg { .visx-bar-group .visx-bar { stroke: black; @@ -27,26 +25,145 @@ padding: 3px; } - button { - font-family: Lato; - border: 1.5px solid #949494; - border-radius: 5px; + } + + } + + div.bar-graph-legend { + height: 100%; + width: 186px; + border-left: 1.5px solid #0592af; + padding: 0 10px 0 5px; + overflow: auto; + text-align: left; + + .dataset-header { + display: flex; + align-items: top; + + .dataset-icon { + margin-right: 7px; + + &:hover { + background-color: $workspace-teal-light-6; + } + } + + .dataset-label { + .dataset-label-text { + display: block; + } + .dataset-name { + display: block; + font-weight: bold; + } + } + } + + .sort-by { + margin: 10px 0 10px 5px; + } + + button.chakra-menu__menu-button { + background-color: $workspace-teal-light-8; + width: 100%; + margin-top: 5px; + } + + .secondary-value { + display: flex; + margin: 5px 0 5px 5px; + align-items: center; + + .color-button { + width: 26px; + height: 26px; + margin: 0 7px 0 0; padding: 5px; + background-color: $workspace-teal-light-8; + border-radius: 5px; + border: solid 1.5px #949494; - .button-content { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - svg { - max-height: 1em; - max-width: 1em; - margin-left: 1em; - opacity: .35; - } + .color-swatch { + border: 1px solid black; + width: 100%; + height: 100%; + margin: 0; } + } + + .secondary-value-name.missing { + font-style: italic; + } + } + } + + // Overrides for vertical (legend underneath) layout + &.vertical { + flex-direction: column; + + div.bar-graph-legend { + width: 100%; + height: auto; + border-top: 1.5px solid #0592af; + border-left: none; + padding: 8px 0 0 0; + + .dataset-header { + margin-left: 5px; + align-items: center; + + .dataset-label-text, .dataset-name { + display: inline-block; + margin-right: 5px; + } + } + + .sort-by { + margin: 0 0 10px 20px; + + button.chakra-menu__menu-button { + width: auto; + margin: 5px 0 0 5px; + } + } + + .secondary-values { + display: flex; + flex-wrap: wrap; + margin-left: 8px; + + .color-button { + margin: 0 7px 0 7px; + } + } + } + } + + button.chakra-menu__menu-button { + font-family: Lato; + border: 1.5px solid #949494; + border-radius: 5px; + padding: 5px; + + .button-content { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + + .button-text { + white-space: nowrap; + } + svg { + width: 100%; + height: 100%; + max-height: 1em; + max-width: 1em; + margin-left: .75em; + opacity: .35; } } diff --git a/src/plugins/bar-graph/category-pulldown.tsx b/src/plugins/bar-graph/category-pulldown.tsx index d3a78264c5..67c5d1a045 100644 --- a/src/plugins/bar-graph/category-pulldown.tsx +++ b/src/plugins/bar-graph/category-pulldown.tsx @@ -1,13 +1,12 @@ import React from "react"; +import { observer } from "mobx-react"; import { Menu, MenuButton, MenuItem, MenuList, Portal } from "@chakra-ui/react"; import { useReadOnlyContext } from "../../components/document/read-only-context"; +import { useBarGraphModelContext } from "./bar-graph-content-context"; -import DropdownCaretIcon from "../dataflow/assets/icons/dropdown-caret.svg"; - +import DropdownCaretIcon from "../../assets/dropdown-caret.svg"; interface IProps { - categoryList: string[]; - category: string; setCategory: (category: string) => void; x: number; y: number; @@ -15,26 +14,36 @@ interface IProps { height: number; } -export function CategoryPulldown({categoryList, category, setCategory, x, y, width, height}: IProps) { +export const CategoryPulldown = observer(function CategoryPulldown({setCategory, x, y, width, height}: IProps) { const readOnly = useReadOnlyContext(); + const model = useBarGraphModelContext(); + + const dataSet = model?.sharedModel?.dataSet; + const attributes = dataSet?.attributes || []; + const current = (dataSet && model.primaryAttribute) + ? dataSet.attrFromID(model.primaryAttribute)?.name + : "Categories"; return ( - + - {category} + {current} - {categoryList.map((c) => ( - setCategory(c)}>{c} + {attributes.map((a) => ( + setCategory(a.id)}>{a.name} ))} ); -} +}); + +export default CategoryPulldown; + diff --git a/src/plugins/bar-graph/chart-area.tsx b/src/plugins/bar-graph/chart-area.tsx new file mode 100644 index 0000000000..f250997188 --- /dev/null +++ b/src/plugins/bar-graph/chart-area.tsx @@ -0,0 +1,194 @@ +import React, { useMemo } from "react"; +import { observer } from "mobx-react"; +import { AxisBottom, AxisLeft } from "@visx/axis"; +import { GridRows } from "@visx/grid"; +import { Group } from "@visx/group"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import { Bar, BarGroup } from "@visx/shape"; +import { useBarGraphModelContext } from "./bar-graph-content-context"; +import { CategoryPulldown } from "./category-pulldown"; +import EditableAxisLabel from "./editable-axis-label"; +import { logBarGraphEvent, roundTo5 } from "./bar-graph-utils"; + +const margin = { + top: 7, + bottom: 70, + left: 70, + right: 10, +}; + +interface IProps { + width: number; + height: number; +} + +// Consider: rotating labels if needed +// angle: -45, textAnchor: 'end' +// https://github.com/airbnb/visx/discussions/1494 + + +export const ChartArea = observer(function BarGraphChart({ width, height }: IProps) { + + const model = useBarGraphModelContext(); + const primary = model?.primaryAttribute || ""; + const secondary = model?.secondaryAttribute; + + const xMax = width - margin.left - margin.right; + const yMax = height - margin.top - margin.bottom; + + function setPrimaryAttribute(id: string) { + if (model) { + model.setPrimaryAttribute(id); + logBarGraphEvent(model, "setPrimaryAttribute", { attributeId: id }); + } + } + + function barColor(key: string) { + if (!model) return "black"; + return model.getColorForSecondaryKey(key); + } + + // Count cases and make the data array + const data = model?.dataArray || []; + + const primaryKeys = useMemo(() => model?.primaryKeys || [], [model?.primaryKeys]); + const secondaryKeys = useMemo(() => model?.secondaryKeys || [], [model?.secondaryKeys]); + + // find the maximum data value + const maxValue = model?.maxDataValue || 0; + + const primaryScale = useMemo( + () => + scaleBand({ + domain: primaryKeys, + paddingInner: (secondary ? 0.2 : .66), + paddingOuter: (secondary ? 0.2 : .33), + range: [0, xMax]}), + [secondary, xMax, primaryKeys]); + + const secondaryScale = useMemo( + () => + scaleBand({ + domain: secondaryKeys, + padding: 0.4, + range: [0, primaryScale.bandwidth()]}), + [primaryScale, secondaryKeys]); + + const countScale = useMemo( + () => + scaleLinear({ + domain: [0, roundTo5(maxValue)], + range: [yMax, 0], + }), + [yMax, maxValue]); + + if (xMax <= 0 || yMax <= 0) return Tile too small to show graph ({width}x{height}); + + const ticks = data.length > 0 + ? Math.min(4, Math.floor(yMax/40)) // leave generous vertical space (>=40 px) between ticks + : 0; // no ticks or grid for empty graph + + const labelWidth = (xMax/primaryKeys.length)-10; // setting width will wrap lines when needed + + function simpleBars() { + const color = barColor(primary); + return ( + + {data.map((d) => { + const key = d[primary] as string; + const val = d.value as number; + return ( + + ); + })} + + ); + } + + function groupedBars() { + return ( + d[primary] as string} + x0Scale={primaryScale} + x1Scale={secondaryScale} + yScale={countScale} + > + {(barGroups) => + + {barGroups.map((barGroup) => ( + + {barGroup.bars.map((bar) => { + if (!bar.value) return null; + return ; + })} + + ))} + + } + + ); + } + + return ( + + + + + Number(value).toFixed(0)} + /> + { secondary ? groupedBars() : simpleBars() } + + + + + ); +}); diff --git a/src/plugins/bar-graph/editable-axis-label.tsx b/src/plugins/bar-graph/editable-axis-label.tsx index 5b625079d1..6af8758e31 100644 --- a/src/plugins/bar-graph/editable-axis-label.tsx +++ b/src/plugins/bar-graph/editable-axis-label.tsx @@ -1,25 +1,26 @@ import React, { useEffect } from 'react'; +import { observer } from 'mobx-react'; import { Text } from '@visx/text'; -import { getBBox } from './bar-graph-utils'; +import { getBBox, logBarGraphEvent } from './bar-graph-utils'; import { useReadOnlyContext } from '../../components/document/read-only-context'; +import { useBarGraphModelContext } from './bar-graph-content-context'; const paddingX = 5, paddingY = 10; -interface Props { +interface IProps { x: number; y: number; - text?: string; - setText: (text: string) => void; } -const EditableAxisLabel: React.FC = ({text, x, y, setText}) => { - +const EditableAxisLabel: React.FC = observer(function EditableAxisLabel({x, y}) { + const model = useBarGraphModelContext(); const readOnly = useReadOnlyContext(); const textRef = React.useRef(null); const [boundingBox, setBoundingBox] = React.useState(null); const [editing, setEditing] = React.useState(false); - const [displayText, setDisplayText] = React.useState(text || "Y axis"); - const [editText, setEditText] = React.useState(text || "Y axis"); + const [editText, setEditText] = React.useState(""); + + const displayText = model?.yAxisLabel || ""; useEffect(() => { if (textRef.current) { @@ -28,12 +29,19 @@ const EditableAxisLabel: React.FC = ({text, x, y, setText}) => { } }, [x, y, displayText, textRef]); - const handleClose = (accept: boolean) => { + const handleStartEdit = () => { + if (!readOnly) { + setEditText(displayText); + setEditing(true); + } + }; + + const handleEndEdit = (accept: boolean) => { setEditing(false); - if (accept && editText) { + if (model && accept && editText) { const trimmed = editText.trim(); - setDisplayText(trimmed); - setText(trimmed); + model.setYAxisLabel(trimmed); + logBarGraphEvent(model, "setYAxisLabel", { text: trimmed }); } }; @@ -41,11 +49,11 @@ const EditableAxisLabel: React.FC = ({text, x, y, setText}) => { const { key } = e; switch (key) { case "Escape": - handleClose(false); + handleEndEdit(false); break; case "Enter": case "Tab": - handleClose(true); + handleEndEdit(true); break; } }; @@ -59,7 +67,7 @@ const EditableAxisLabel: React.FC = ({text, x, y, setText}) => { value={editText} size={editText.length + 5} onKeyDown={handleKeyDown} - onBlur={() => handleClose(true)} + onBlur={() => handleEndEdit(true)} onChange={(e) => setEditText(e.target.value)} /> @@ -81,7 +89,7 @@ const EditableAxisLabel: React.FC = ({text, x, y, setText}) => { strokeWidth={1.5} fill="none" pointerEvents={editing ? "none" : "all"} - onClick={() => { if (!readOnly) setEditing(true); }} + onClick={handleStartEdit} />} = ({text, x, y, setText}) => { ); -}; +}); export default EditableAxisLabel; diff --git a/src/plugins/bar-graph/legend-area.tsx b/src/plugins/bar-graph/legend-area.tsx new file mode 100644 index 0000000000..7625c98e92 --- /dev/null +++ b/src/plugins/bar-graph/legend-area.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import { Menu, MenuButton, MenuItem, MenuList, Portal } from '@chakra-ui/react'; +import { useBarGraphModelContext } from './bar-graph-content-context'; +import { LegendSecondaryRow } from './legend-secondary-row'; + +import RemoveDataIcon from "../../assets/remove-data-icon.svg"; +import DropdownCaretIcon from "../../assets/dropdown-caret.svg"; +import { useReadOnlyContext } from '../../components/document/read-only-context'; +import { logBarGraphEvent } from './bar-graph-utils'; +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 { getSharedModelManager } from '../../models/tiles/tile-environment'; + +interface IProps { + legendRef: React.RefObject; +} + +export const LegendArea = observer(function LegendArea ({legendRef}: IProps) { + const { tile } = useTileModelContext(); + const model = useBarGraphModelContext(); + const readOnly = useReadOnlyContext(); + + function unlinkDataset() { + const sharedModel = model?.sharedModel; + if (!readOnly && sharedModel) { + model.unlinkDataSet(); + if (tile) { + const sharedTiles = getSharedModelManager()?.getSharedModelProviders(sharedModel) || []; + logSharedModelDocEvent(LogEventName.TILE_UNLINK, tile, sharedTiles, sharedModel); + } + } + } + + function setSecondaryAttribute(attributeId: string|undefined) { + if (model) { + model.setSecondaryAttribute(attributeId); + logBarGraphEvent(model, "setSecondaryAttribute", { attributeId }); + } + } + + if (!model || !model.sharedModel || !model.primaryAttribute) { + return null; + } + + const dataSet = model.sharedModel.dataSet; + const dataSetName = model.sharedModel.name; + const allAttributes = dataSet?.attributes || []; + const availableAttributes = allAttributes.filter((a) => a.id !== model.primaryAttribute); + const currentPrimary = dataSet?.attrFromID(model.primaryAttribute); + const currentSecondary = model.secondaryAttribute ? dataSet?.attrFromID(model.secondaryAttribute) : undefined; + const currentLabel = currentSecondary?.name || "None"; + + const secondaryKeys = model.secondaryKeys; + + return ( +
+
+
+
+ + + +
+
+ Data from: + {dataSetName} +
+
+ +
+ + Sort by: + + + + + {currentLabel} + + + + + + setSecondaryAttribute(undefined)}>None + {availableAttributes.map((a) => ( + setSecondaryAttribute(a.id)}> + {a.name} + + ))} + + + +
+ +
+ {currentSecondary + ? secondaryKeys.map((key) => ) + : } +
+
+
+ ); +}); + +export default LegendArea; diff --git a/src/plugins/bar-graph/legend-secondary-row.tsx b/src/plugins/bar-graph/legend-secondary-row.tsx new file mode 100644 index 0000000000..35f3344802 --- /dev/null +++ b/src/plugins/bar-graph/legend-secondary-row.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useBarGraphModelContext } from './bar-graph-content-context'; +import { isMissingData } from './bar-graph-utils'; + +interface IProps { + attrValue: string; +} + +export function LegendSecondaryRow({attrValue}: IProps) { + const model = useBarGraphModelContext(); + if (!model) return null; + + const missingData = isMissingData(attrValue); + + return ( +
+
+
+
+
+ {attrValue} +
+
+ ); +} + +export default LegendSecondaryRow; diff --git a/src/plugins/dataflow/nodes/controls/dropdown-list-control.tsx b/src/plugins/dataflow/nodes/controls/dropdown-list-control.tsx index f9c9d07cb5..87aedf9c70 100644 --- a/src/plugins/dataflow/nodes/controls/dropdown-list-control.tsx +++ b/src/plugins/dataflow/nodes/controls/dropdown-list-control.tsx @@ -6,7 +6,7 @@ import classNames from "classnames"; import { useStopEventPropagation, useCloseDropdownOnOutsideEvent } from "./custom-hooks"; import { IBaseNode, IBaseNodeModel } from "../base-node"; -import DropdownCaretIcon from "../../assets/icons/dropdown-caret.svg"; +import DropdownCaretIcon from "../../../../assets/dropdown-caret.svg"; import "./dropdown-list-control.scss"; diff --git a/src/plugins/graph/components/legend/layer-legend.tsx b/src/plugins/graph/components/legend/layer-legend.tsx index 257692d54a..1f09b5ea3f 100644 --- a/src/plugins/graph/components/legend/layer-legend.tsx +++ b/src/plugins/graph/components/legend/layer-legend.tsx @@ -18,7 +18,7 @@ import { useTileModelContext } from "../../../../components/tiles/hooks/use-tile 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 RemoveDataIcon from "../../../../assets/remove-data-icon.svg"; import XAxisIcon from "../../assets/x-axis-icon.svg"; import YAxisIcon from "../../assets/y-axis-icon.svg"; diff --git a/src/plugins/graph/graph-registration.ts b/src/plugins/graph/graph-registration.ts index 7d050940b6..b09eb95294 100644 --- a/src/plugins/graph/graph-registration.ts +++ b/src/plugins/graph/graph-registration.ts @@ -5,10 +5,10 @@ import { GraphWrapperComponent } from "./components/graph-wrapper-component"; import { createGraphModel, GraphModel } from "./models/graph-model"; import { updateGraphContentWithNewSharedModelIds, updateGraphObjectWithNewSharedModelIds } from "./utilities/graph-utils"; +import { AppConfigModelType } from "../../models/stores/app-config-model"; import Icon from "./assets/graph-icon.svg"; import HeaderIcon from "./assets/graph-tile-id.svg"; -import { AppConfigModelType } from "../../models/stores/app-config-model"; function graphAllowsMultipleDataSets(appConfig: AppConfigModelType) { return !!appConfig.getSetting("defaultSeriesLegend", "graph"); diff --git a/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx b/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx index 375c3655bb..0137f5ab8e 100644 --- a/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx +++ b/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx @@ -16,8 +16,8 @@ import { } from "../plotted-variables-adornment/plotted-variables-adornment-model"; import { VariableSelection } from "./variable-selection"; +import RemoveDataIcon from "../../../../assets/remove-data-icon.svg"; import AddSeriesIcon from "../../../graph/imports/assets/add-series-icon.svg"; -import RemoveDataIcon from "../../../graph/assets/remove-data-icon.svg"; import XAxisIcon from "../../../graph/assets/x-axis-icon.svg"; import YAxisIcon from "../../../graph/assets/y-axis-icon.svg";