this.gridElement = el}
+// draggable={true}
+// onDragStart={this.handleDragStart}
+// onKeyUp={this.handleKeyUp}>
+//
+// {/* hidden ruler div for measuring header text width */}
+//
+// );
+// }
+
+// private measureHeaderLabelWidth(label: string) {
+// if (this.headerRulerElement) {
+// this.headerRulerElement.innerText = label;
+// const width = Math.ceil(this.headerRulerElement.offsetWidth);
+// this.headerRulerElement.innerText = "";
+// return width;
+// }
+// }
+
+// private handleDragStart = (evt: React.DragEvent
) => {
+// // ag-grid adds "ag-column-resizing" class to columns being actively resized
+// if (this.gridElement?.getElementsByClassName("ag-column-resizing").length) {
+// // if we're column resizing, prevent other drags above (e.g. tile drags)
+// evt.preventDefault();
+// evt.stopPropagation();
+// }
+// }
+// }
diff --git a/src/components/tools/table-tool/editable-header-cell.tsx b/src/components/tools/table-tool/editable-header-cell.tsx
new file mode 100644
index 0000000000..725e4f8f36
--- /dev/null
+++ b/src/components/tools/table-tool/editable-header-cell.tsx
@@ -0,0 +1,49 @@
+import React, { useState } from "react";
+import { TColumn, THeaderRendererProps } from "./table-types";
+import { HeaderCellInput } from "./header-cell-input";
+
+interface IProps extends THeaderRendererProps {
+}
+export const EditableHeaderCell: React.FC = ({ column: _column }) => {
+ const column = _column as unknown as TColumn;
+ const { name, appData } = column;
+ const {
+ gridContext, editableName, isEditing,
+ onBeginHeaderCellEdit, onHeaderCellEditKeyDown, onEndHeaderCellEdit
+ } = appData || {};
+ const [nameValue, setNameValue] = useState(editableName ? name as string : "");
+ const handleClick = () => {
+ !isEditing && gridContext?.onSelectColumn(column.key);
+ };
+ const handleDoubleClick = () => {
+ editableName && !isEditing && onBeginHeaderCellEdit?.();
+ };
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const { key } = e;
+ switch (key) {
+ case "Escape":
+ handleClose(false);
+ break;
+ case "Enter":
+ case "Tab":
+ handleClose(true);
+ onHeaderCellEditKeyDown?.(e);
+ break;
+ }
+ };
+ const handleChange = (value: string) => {
+ setNameValue(value);
+ };
+ const handleClose = (accept: boolean) => {
+ onEndHeaderCellEdit?.(accept ? nameValue : undefined);
+ };
+ const style = { width: column.width };
+ return (
+
+ {isEditing
+ ?
+ : name}
+
+ );
+};
diff --git a/src/components/tools/table-tool/editable-table-title.tsx b/src/components/tools/table-tool/editable-table-title.tsx
new file mode 100644
index 0000000000..d997398742
--- /dev/null
+++ b/src/components/tools/table-tool/editable-table-title.tsx
@@ -0,0 +1,67 @@
+import classNames from "classnames";
+import { observer } from "mobx-react";
+import React, { useState } from "react";
+import { HeaderCellInput } from "./header-cell-input";
+import { LinkGeometryButton } from "./link-geometry-button";
+
+interface IProps {
+ className?: string;
+ readOnly?: boolean;
+ showLinkButton: boolean;
+ linkIndex: number;
+ isLinkEnabled?: boolean;
+ titleCellWidth: number;
+ getTitle: () => string | undefined;
+ onBeginEdit?: () => void;
+ onEndEdit?: (title?: string) => void;
+ onLinkGeometryClick?: () => void;
+}
+export const EditableTableTitle: React.FC = observer(({
+ className, readOnly, showLinkButton, linkIndex, isLinkEnabled, titleCellWidth,
+ getTitle, onBeginEdit, onEndEdit, onLinkGeometryClick
+}) => {
+ // getTitle() and observer() allow this component to re-render
+ // when the title changes without re-rendering the entire TableTool
+ const title = getTitle();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editingTitle, setEditingTitle] = useState(title);
+ const handleClick = () => {
+ if (!readOnly && !isEditing) {
+ onBeginEdit?.();
+ setEditingTitle(title);
+ setIsEditing(true);
+ }
+ };
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const { key } = e;
+ switch (key) {
+ case "Escape":
+ handleClose(false);
+ break;
+ case "Enter":
+ case "Tab":
+ handleClose(true);
+ break;
+ }
+ };
+ const handleClose = (accept: boolean) => {
+ const trimTitle = editingTitle?.trim();
+ onEndEdit?.(accept && trimTitle ? trimTitle : undefined);
+ setIsEditing(false);
+ };
+ const isDefaultTitle = title && /Table\s+(\d+)\s*$/.test(title);
+ const classes = classNames("editable-header-cell", className,
+ { "table-title-editing": isEditing, "table-title-default": isDefaultTitle });
+ const style = { width: titleCellWidth };
+ return (
+
+ {isEditing
+ ?
+ : title}
+ {showLinkButton && !isEditing &&
+ }
+
+ );
+});
diff --git a/src/components/tools/table-tool/expression-utils.ts b/src/components/tools/table-tool/expression-utils.ts
new file mode 100644
index 0000000000..36e60c1952
--- /dev/null
+++ b/src/components/tools/table-tool/expression-utils.ts
@@ -0,0 +1,52 @@
+import { Parser } from "expr-eval";
+import { kSerializedXKey } from "../../../models/tools/table/table-model-types";
+
+export const getEditableExpression = (
+ rawExpression: string | undefined, canonicalExpression: string, xName: string
+) => {
+ // Raw expressions are cleared when x attribute is renamed, in which case
+ // we regenerate the "raw" expression from the canonical expression.
+ return rawExpression || prettifyExpression(canonicalExpression, xName);
+};
+
+export const canonicalizeExpression = (displayExpression: string, xName: string) => {
+ if (xName && displayExpression) {
+ const parser = new Parser();
+ const canonicalExpression = parser.parse(displayExpression).substitute(xName, kSerializedXKey);
+ return canonicalExpression.toString();
+ } else {
+ return displayExpression;
+ }
+};
+
+export const prettifyExpression = (canonicalExpression: string | undefined, xName: string) => {
+ if (xName && canonicalExpression) {
+ const parser = new Parser();
+ let expression = parser.parse(canonicalExpression).substitute(kSerializedXKey, xName).toString();
+ if (expression.charAt(0) === "(" && expression.charAt(expression.length - 1) === ")") {
+ expression = expression.substring(1, expression.length - 1);
+ }
+ return expression;
+ } else {
+ return canonicalExpression;
+ }
+};
+
+export const validateExpression = (expressionStr: string, xName: string) => {
+ if (!expressionStr || !xName) return;
+ const parser = new Parser();
+ try {
+ const expression = parser.parse(expressionStr);
+ const unknownVar = expression.variables().find(variable => variable !== xName);
+ if (unknownVar) {
+ return `Unrecognized variable "${unknownVar}" in expression.`;
+ }
+ if (xName) {
+ // Attempt an evaluation to check for errors e.g. invalid function names
+ expression.evaluate({[xName]: 1});
+ }
+ } catch {
+ return "Could not understand expression. Make sure you supply all operands " +
+ "and use a multiplication sign where necessary, e.g. 3 * x + 4 instead of 3x + 4.";
+ }
+};
diff --git a/src/components/tools/table-tool/expressions-dialog.scss b/src/components/tools/table-tool/expressions-dialog.scss
new file mode 100644
index 0000000000..1d9367856f
--- /dev/null
+++ b/src/components/tools/table-tool/expressions-dialog.scss
@@ -0,0 +1,57 @@
+@import "../../../components/vars.sass";
+
+.custom-modal.set-expression {
+ width: 512px;
+ height: 237px;
+ outline: none;
+
+ .modal-content {
+ justify-content: flex-start;
+
+ .prompt {
+ margin-top: 15px;
+
+ select {
+ margin: 0 3px;
+ padding: 0 3px;
+ font-weight: bold;
+ }
+
+ .attr-name {
+ font-weight: bold;
+
+ &.y {
+ margin: 0 3px;
+ }
+
+ &.x {
+ margin-left: 3px;
+ }
+ }
+ }
+
+ .expression {
+ margin-top: 20px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+
+ label {
+ display: inline-block;
+ flex: 0 0 auto;
+
+ .equals {
+ margin: 0 6px 0 3px;
+ }
+ }
+ input {
+ height: 100%;
+ flex: 1 1 auto;
+ }
+ }
+
+ .error {
+ margin-top: 15px;
+ }
+ }
+}
diff --git a/src/components/tools/table-tool/header-cell-input.tsx b/src/components/tools/table-tool/header-cell-input.tsx
new file mode 100644
index 0000000000..f8ac4ab1b8
--- /dev/null
+++ b/src/components/tools/table-tool/header-cell-input.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+function autoFocusAndSelect(input: HTMLInputElement | null) {
+ input?.focus();
+ input?.select();
+}
+
+interface IProps {
+ style?: React.CSSProperties;
+ value: string;
+ onKeyDown: (e: React.KeyboardEvent) => void;
+ onChange: (value: string) => void;
+ onClose: (accept: boolean) => void;
+}
+export const HeaderCellInput: React.FC = ({ style, value, onKeyDown, onChange, onClose }) => {
+ return (
+
+ onChange(event.target.value)}
+ onBlur={() => onClose(true)}
+ />
+
+ );
+};
diff --git a/src/components/tools/table-tool/link-geometry-button.tsx b/src/components/tools/table-tool/link-geometry-button.tsx
new file mode 100644
index 0000000000..4321e4c181
--- /dev/null
+++ b/src/components/tools/table-tool/link-geometry-button.tsx
@@ -0,0 +1,21 @@
+import classNames from "classnames";
+import React from "react";
+import LinkGraphIcon from "../../../clue/assets/icons/table/link-graph-icon.svg";
+
+interface IProps {
+ linkIndex: number;
+ isEnabled?: boolean;
+ onClick?: () => void;
+}
+export const LinkGeometryButton: React.FC = ({ isEnabled, linkIndex, onClick }) => {
+ const classes = classNames("link-geometry-button", `link-color-${linkIndex}`, { disabled: !isEnabled });
+ const handleClick = (e: React.MouseEvent) => {
+ isEnabled && onClick?.();
+ e.stopPropagation();
+ };
+ return (
+
+
+
+ );
+};
diff --git a/src/components/tools/table-tool/link-geometry-dialog.scss b/src/components/tools/table-tool/link-geometry-dialog.scss
new file mode 100644
index 0000000000..d33b3a0979
--- /dev/null
+++ b/src/components/tools/table-tool/link-geometry-dialog.scss
@@ -0,0 +1,24 @@
+@import "../../../components/vars.sass";
+
+.custom-modal.link-geometry {
+ width: 420px;
+ height: 200px;
+ outline: none;
+
+ .modal-content {
+ justify-content: flex-start;
+
+ .prompt {
+ margin-top: 15px;
+ }
+ }
+
+ select {
+ margin: 15px 20px;
+ font-style: italic;
+ }
+
+ .modal-button.disabled {
+ opacity: 35%;
+ }
+};
diff --git a/src/components/tools/table-tool/linked-table-cell-editor.tsx b/src/components/tools/table-tool/linked-table-cell-editor.tsx
index 83573219f3..3edffa4bd4 100644
--- a/src/components/tools/table-tool/linked-table-cell-editor.tsx
+++ b/src/components/tools/table-tool/linked-table-cell-editor.tsx
@@ -1,56 +1,56 @@
-import { ICellEditorParams, TextCellEditor } from "@ag-grid-community/core";
-import { TableMetadataModelType } from "../../../models/tools/table/table-content";
-
-interface ILinkedTableCellEditorParams extends ICellEditorParams {
- metadata: TableMetadataModelType;
-}
-
-export class LinkedTableCellEditor extends TextCellEditor {
-
- private domInput: HTMLInputElement;
- private metadata: TableMetadataModelType;
-
- constructor() {
- super();
- this.domInput = this.getGui().querySelector("input") as HTMLInputElement;
- }
-
- public init(params: ICellEditorParams) {
- super.init(params as any);
- const _params = params as ILinkedTableCellEditorParams;
- this.metadata = _params.metadata;
- }
-
- public afterGuiAttached() {
- super.afterGuiAttached();
- const eInput = this.domInput;
- eInput && eInput.addEventListener("input", this.handleInputChange);
- }
-
- public destroy() {
- const eInput = this.domInput;
- eInput && eInput.removeEventListener("input", this.handleInputChange);
- }
-
- public handleInputChange = (e: any) => {
- const value = e.target.value;
- if (!this.isValid(value)) {
- this.domInput.classList.add("invalid-cell");
- } else {
- this.domInput.classList.remove("invalid-cell");
- }
- }
-
- public isValid = (value: string) => {
- // don't apply validation unless the table is linked
- if (!this.metadata || !this.metadata.isLinked) return true;
- // allow empty values
- if ((value == null) || (value === "")) return true;
- // non-empty values must be numeric
- return isFinite(Number(value));
- }
-
- public isCancelAfterEnd = () => {
- return !this.isValid(this.getValue());
- }
-}
+// import { ICellEditorParams, TextCellEditor } from "@ag-grid-community/core";
+// import { TableMetadataModelType } from "../../../models/tools/table/table-content";
+
+// interface ILinkedTableCellEditorParams extends ICellEditorParams {
+// metadata: TableMetadataModelType;
+// }
+
+// export class LinkedTableCellEditor extends TextCellEditor {
+
+// private domInput: HTMLInputElement;
+// private metadata: TableMetadataModelType;
+
+// constructor() {
+// super();
+// this.domInput = this.getGui().querySelector("input") as HTMLInputElement;
+// }
+
+// public init(params: ICellEditorParams) {
+// super.init(params as any);
+// const _params = params as ILinkedTableCellEditorParams;
+// this.metadata = _params.metadata;
+// }
+
+// public afterGuiAttached() {
+// super.afterGuiAttached();
+// const eInput = this.domInput;
+// eInput && eInput.addEventListener("input", this.handleInputChange);
+// }
+
+// public destroy() {
+// const eInput = this.domInput;
+// eInput && eInput.removeEventListener("input", this.handleInputChange);
+// }
+
+// public handleInputChange = (e: any) => {
+// const value = e.target.value;
+// if (!this.isValid(value)) {
+// this.domInput.classList.add("invalid-cell");
+// } else {
+// this.domInput.classList.remove("invalid-cell");
+// }
+// }
+
+// public isValid = (value: string) => {
+// // don't apply validation unless the table is linked
+// if (!this.metadata || !this.metadata.isLinked) return true;
+// // allow empty values
+// if ((value == null) || (value === "")) return true;
+// // non-empty values must be numeric
+// return isFinite(Number(value));
+// }
+
+// public isCancelAfterEnd = () => {
+// return !this.isValid(this.getValue());
+// }
+// }
diff --git a/src/components/tools/table-tool/new-column-dialog.tsx b/src/components/tools/table-tool/new-column-dialog.tsx
deleted file mode 100644
index 5aea3b09ca..0000000000
--- a/src/components/tools/table-tool/new-column-dialog.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from "react";
-import { Button, Dialog } from "@blueprintjs/core";
-
-interface IProps {
- isOpen: boolean;
- onNewAttribute: (name: string) => void;
- onClose: () => void;
-}
-
-interface IState {
- name: string;
-}
-
-export default
-class NewColumnDialog extends React.Component {
-
- constructor(props: IProps) {
- super(props);
-
- this.state = {
- name: ""
- };
- }
-
- public render() {
- const capitalizedAttribute = "Attribute";
- return (
-
- );
- }
-
- private handleNameChange = (evt: React.FormEvent) => {
- this.setState({ name: (evt.target as HTMLInputElement).value });
- }
-
- private handleNewAttribute = () => {
- if (this.props.onNewAttribute) {
- this.props.onNewAttribute(this.state.name);
- }
- }
-
- private handleKeyDown = (evt: React.KeyboardEvent) => {
- if (evt.keyCode === 13) {
- this.handleNewAttribute();
- }
- }
-
-}
diff --git a/src/components/tools/table-tool/rename-column-dialog.sass b/src/components/tools/table-tool/rename-column-dialog.sass
deleted file mode 100644
index 97aa7fd306..0000000000
--- a/src/components/tools/table-tool/rename-column-dialog.sass
+++ /dev/null
@@ -1,4 +0,0 @@
-.bp3-dialog.rename-column-dialog
-
- .nc-dialog-error
- height: 36px
\ No newline at end of file
diff --git a/src/components/tools/table-tool/rename-column-dialog.tsx b/src/components/tools/table-tool/rename-column-dialog.tsx
deleted file mode 100644
index 9d38cd206a..0000000000
--- a/src/components/tools/table-tool/rename-column-dialog.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from "react";
-import { Button, Dialog } from "@blueprintjs/core";
-
-import "./rename-column-dialog.sass";
-
-interface IProps {
- id: string;
- isOpen: boolean;
- onRenameAttribute: (id: string, name: string) => void;
- onClose: () => void;
- name: string;
- columnNameValidator?: (name: string) => string | undefined;
-}
-
-interface IState {
- name: string;
-}
-
-export default
-class RenameColumnDialog extends React.Component {
-
- public state = {
- name: this.props.name || ""
- };
-
- public render() {
- const prompt = `Enter a new name for column "${this.props.name}"`;
- const errorMessage = this.getValidationError();
- return (
-
- );
- }
-
- private getValidationError = () => {
- const { columnNameValidator: validator } = this.props;
- const { name } = this.state;
- return validator && validator(name);
- }
-
- private handleNameChange = (evt: React.FormEvent) => {
- this.setState({ name: (evt.target as HTMLInputElement).value });
- }
-
- private handleRenameAttribute = () => {
- if (this.props.onRenameAttribute && !this.getValidationError()) {
- this.props.onRenameAttribute(this.props.id, this.state.name);
- }
- }
-
- private handleKeyDown = (evt: React.KeyboardEvent) => {
- if (evt.keyCode === 13) {
- this.handleRenameAttribute();
- }
- }
-
-}
diff --git a/src/components/tools/table-tool/set-table-name-dialog.tsx b/src/components/tools/table-tool/set-table-name-dialog.tsx
deleted file mode 100644
index 331dda4c8f..0000000000
--- a/src/components/tools/table-tool/set-table-name-dialog.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as React from "react";
-const { useState } = React;
-import { Button, Dialog } from "@blueprintjs/core";
-
-interface IProps {
- isOpen: boolean;
- tableName?: string;
- maxLength?: number;
- onSetTableName: (name: string) => void;
- onClose: () => void;
-}
-
-const kDefaultMaxLength = 60;
-
-export const SetTableNameDialog: React.FC = ({ isOpen, tableName, maxLength, onSetTableName, onClose }) => {
-
- const [name, setName] = useState(tableName || "");
-
- const handleChange = (e: React.FormEvent) => {
- setName((e.target as HTMLInputElement).value);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- (e.keyCode === 13) && onSetTableName(name);
- };
-
- const handleOkClick = () => {
- onSetTableName(name);
- };
-
- return (
-
- );
-};
diff --git a/src/components/tools/table-tool/table-header-menu.tsx b/src/components/tools/table-tool/table-header-menu.tsx
deleted file mode 100644
index 126c04fdb5..0000000000
--- a/src/components/tools/table-tool/table-header-menu.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-import React from "react";
-import NewColumnDialog from "./new-column-dialog";
-import RenameColumnDialog from "./rename-column-dialog";
-import { SetTableNameDialog } from "./set-table-name-dialog";
-import { IDataSet } from "../../../models/data/data-set";
-import { GridApi } from "@ag-grid-community/core";
-import { Icon, Menu, Popover, Position, MenuDivider, MenuItem, Alert, Intent } from "@blueprintjs/core";
-import { listenForTableEvents } from "../../../models/tools/table/table-events";
-import UpdateExpressionDialog from "./update-expression-dialog";
-
-export interface IMenuItemFlags {
- addAttribute?: boolean;
- addCase?: boolean;
- addRemoveDivider?: boolean;
- setTableName?: boolean;
- renameAttribute?: boolean;
- removeAttribute?: boolean;
- removeCases?: boolean;
- unlinkGeometry?: boolean;
-}
-
-export interface IProps {
- api: GridApi;
- expressions?: Map;
- rawExpressions?: Map;
- dataSet?: IDataSet;
- readOnly?: boolean;
- itemFlags?: IMenuItemFlags;
- onSetTableName: (name: string) => void;
- onNewAttribute: (name: string) => void;
- onRenameAttribute: (id: string, name: string) => void;
- onUpdateExpression: (id: string, expression: string, rawExpression: string) => void;
- onNewCase: () => void;
- onRemoveAttribute: (id: string) => void;
- onRemoveCases: (ids: string[]) => void;
- onGetLinkedGeometries: () => string[];
- onUnlinkGeometry: () => void;
- onSampleData?: (name: string) => void;
-}
-
-interface IState {
- isNewAttributeDialogOpen?: boolean;
- isTableNameDialogOpen?: boolean;
- tableName?: string;
- isRenameAttributeDialogOpen?: boolean;
- renameAttributeId?: string;
- renameAttributeName?: string;
-
- isUpdateExpressionDialogOpen?: boolean;
- updateExpressionAttributeId?: string;
- showInvalidVariableAlert?: boolean;
-}
-
-export class TableHeaderMenu extends React.Component {
-
- public state: IState = {};
-
- constructor(props: IProps) {
- super(props);
-
- listenForTableEvents((event) => {
- switch (event.type) {
- case "rename-column":
- this.setState({
- isRenameAttributeDialogOpen: true,
- renameAttributeId: event.id,
- renameAttributeName: event.name
- });
- break;
- case "add-column":
- this.setState({
- isNewAttributeDialogOpen: true
- });
- break;
- default:
- break;
- }
- });
- }
-
- public buildColumnNameValidator(columnHasExpression: boolean): (name: string) => string | undefined {
- return (name: string) => {
- if (!name) {
- return "Column must have a non-empty name";
- }
- // TODO: Expand valid variable names to include additional character sets
- if (columnHasExpression && !/^[a-z]+$/i.test(name)) {
- return "Columns with expressions must have single-word names";
- }
- };
- }
-
- public render() {
- if (this.props.readOnly) return null;
- return (
-
-
-
-
-
-
- {this.renderRenameColumnDialog()}
- {this.renderUpdateExpressionDialog()}
- {this.renderInvalidVariableAlert()}
-
- );
- }
-
- private renderRenameColumnDialog() {
- const { expressions } = this.props;
- const nonNullExpression = !!expressions && Array.from(expressions.values()).some(expr => !!expr);
- return this.state.isRenameAttributeDialogOpen && this.state.renameAttributeId
- ?
- : null;
- }
-
- private renderUpdateExpressionDialog() {
- const id = this.state.updateExpressionAttributeId;
- const { dataSet, expressions, rawExpressions } = this.props;
- if (!id || !dataSet || !this.state.isUpdateExpressionDialogOpen) return null;
- const xName = dataSet.attributes[0].name;
- const yName = dataSet.attrFromID(id).name;
- return ;
- }
-
- private openNewAttributeDialog = () => {
- this.setState({ isNewAttributeDialogOpen: true });
- }
-
- private closeNewAttributeDialog = () => {
- this.setState({ isNewAttributeDialogOpen: false });
- }
-
- private closeRenameAttributeDialog = () => {
- this.setState({ isRenameAttributeDialogOpen: false });
- }
-
- private handleRenameAttributeCallback = (id: string, name: string) => {
- this.props.onRenameAttribute(id, name);
- this.closeRenameAttributeDialog();
- }
-
- private handleRenameAttribute = (evt: React.MouseEvent, attrID: string, name?: string) => {
- this.setState({
- isRenameAttributeDialogOpen: true,
- renameAttributeId: attrID,
- renameAttributeName: name || ""
- });
- }
-
- private handleSetTableName = () => {
- this.setState({ isTableNameDialogOpen: true });
- }
-
- private handleCloseTableNameDialog = () => {
- this.setState({ isTableNameDialogOpen: false });
- }
-
- private closeUpdateExpressionDialog = () => {
- this.setState({ isUpdateExpressionDialogOpen: false });
- }
-
- private handleUpdateExpressionCallback = (id: string, expression: string, rawExpression: string) => {
- this.props.onUpdateExpression(id, expression, rawExpression);
- this.closeUpdateExpressionDialog();
- }
-
- private handleUpdateExpression = (evt: React.MouseEvent) => {
- const { dataSet } = this.props;
- const xAttr = dataSet && dataSet.attributes[0];
- const yAttr = dataSet && dataSet.attributes[1];
- if (xAttr && yAttr) {
- if (this.buildColumnNameValidator(true)(xAttr.name) || this.buildColumnNameValidator(true)(yAttr.name)) {
- this.setState({
- showInvalidVariableAlert: true
- });
- } else {
- this.setState({
- isUpdateExpressionDialogOpen: true,
- updateExpressionAttributeId: yAttr.id
- });
- }
- }
- }
-
- private handleNewCase = () => {
- if (this.props.onNewCase) {
- this.props.onNewCase();
- }
- }
-
- private handleRemoveAttribute = (evt: React.MouseEvent, attrID: string) => {
- if (this.props.onRemoveAttribute) {
- this.props.onRemoveAttribute(attrID);
- }
- }
-
- private getSelectedRowNodes() {
- return this.props.api && this.props.api.getSelectedNodes();
- }
-
- private getSelectedRowNodeCount() {
- const selectedNodes = this.getSelectedRowNodes();
- return selectedNodes ? selectedNodes.length : 0;
- }
-
- private handleRemoveCases = (evt: React.MouseEvent) => {
- if (this.props.onRemoveCases) {
- const selectedRows = this.getSelectedRowNodes() || [];
- this.props.onRemoveCases(selectedRows.map(row => row.id));
- }
- }
-
- private handleUnlinkGeometry = () => {
- this.props.onUnlinkGeometry && this.props.onUnlinkGeometry();
- }
-
- private renderAttributeSubMenuItems(onClick: (evt: React.MouseEvent,
- attrID: string, name?: string) => void) {
- if (!this.props.dataSet || !this.props.dataSet.attributes.length) { return null; }
- return this.props.dataSet.attributes.map((attr) => {
- function handleClick(evt: React.MouseEvent) {
- return onClick(evt, attr.id, attr.name);
- }
- return (
-
- );
- });
- }
-
- private renderInvalidVariableAlert() {
- const { showInvalidVariableAlert } = this.state;
- if (!showInvalidVariableAlert) return;
-
- return (
-
-
- The names of all columns must be a single word to create expressions.
-
-
- );
- }
-
- private handleCloseInvalidVariableAlert = () => {
- this.setState({ showInvalidVariableAlert: false });
- }
-
- private renderMenu() {
- const itemFlags = this.props.itemFlags || {};
- const addColumn = itemFlags.addAttribute !== false
- ?
- : null;
- const addRow = itemFlags.addCase !== false
- ?
- : null;
- const addRemoveDivider = itemFlags.addRemoveDivider && ;
- const setTableName = itemFlags.setTableName &&
- ;
- const renameColumn = itemFlags.renameAttribute !== false
- ?
- : null;
- const updateExpression = itemFlags.renameAttribute !== false
- ?
- : null;
- const removeColumn = itemFlags.removeAttribute !== false
- ?
- : null;
- const removeRows = itemFlags.removeCases !== false
- ?
- : null;
- const linkedGeometryCount = this.props.onGetLinkedGeometries
- ? this.props.onGetLinkedGeometries().length
- : 0;
- const unlinkGeometry = itemFlags.unlinkGeometry !== false
- ?
- : null;
- return (
-
- );
- }
-
-}
diff --git a/src/components/tools/table-tool/table-tool.sass b/src/components/tools/table-tool/table-tool.sass
deleted file mode 100644
index 40bde5b016..0000000000
--- a/src/components/tools/table-tool/table-tool.sass
+++ /dev/null
@@ -1,22 +0,0 @@
-@import ../../../models/tools/table-links
-
-.table-tool
- height: 100%
-
-.link-color-0 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-0-light
-
-.link-color-1 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-1-light
-
-.link-color-2 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-2-light
-
-.link-color-3 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-3-light
-
-.link-color-4 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-4-light
-
-.link-color-5 > .ag-theme-fresh .ag-row-selected
- background-color: $link-color-5-light
diff --git a/src/components/tools/table-tool/table-tool.scss b/src/components/tools/table-tool/table-tool.scss
new file mode 100644
index 0000000000..82eddf35af
--- /dev/null
+++ b/src/components/tools/table-tool/table-tool.scss
@@ -0,0 +1,294 @@
+@import "../../vars.sass";
+@import "../../../models/tools/table-links";
+
+$table-padding: 10px;
+$row-height: 34px;
+$index-column-width: 34px;
+$controls-column-width: 36px;
+$platform-scrollbar-width: 16px;
+$header-color: $workspace-teal-light-5;
+$header-input-color: #eef9fb;
+$border-style: 1px solid $charcoal-light-1;
+$border-input-style: 1px solid #dadada;
+$border-radius: 3px;
+$controls-hover-background: #c0dfe7;
+
+.table-tool {
+ position: relative;
+ height: 100%;
+ padding: $table-padding;
+
+ .table-grid-container {
+ position: relative;
+ height: 100%;
+
+ .table-title {
+ position: absolute;
+ left: $index-column-width - 1px;
+ max-width: calc(100% - #{$index-column-width} + #{$table-padding});
+ height: $row-height;
+ color: $charcoal-dark-2;
+ background-color: $workspace-teal-light-7;
+ border-left: $border-style;
+ border-top: $border-style;
+ border-right: $border-style;
+ border-radius: $border-radius $border-radius 0 0;
+ font-weight: bold;
+ padding: 8px;
+
+ &.table-title-default {
+ font-style: italic;
+ }
+
+ &.table-title-editing {
+ padding: 0;
+
+ input {
+ text-align: center;
+ border: solid 1.5px $charcoal-light-1;
+
+ &:focus {
+ border: solid 2px $highlight-blue;
+ }
+ }
+ }
+
+ .link-geometry-button {
+ position: absolute;
+ right: 3.5px;
+ top: 3px;
+ width: 26px;
+ height: 26px;
+ border-radius: 5px;
+ border: solid 1.5px $charcoal-light-1;
+ background-color: $workspace-teal-light-9;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &.disabled {
+ opacity: 35%;
+ }
+
+ &:hover:not(.disabled) {
+ background-color: $workspace-teal-light-4;
+ }
+
+ &:active:not(.disabled) {
+ background-color: $workspace-teal-light-2;
+ }
+ }
+ }
+ }
+
+ .rdg {
+ --color: #{$charcoal-dark-2};
+ --border-color: #{$charcoal-light-1};
+ --header-background-color: #{$header-color};
+ --header-selected-background-color: #{$highlight-blue-50};
+ --row-selected-background-color: #{$highlight-blue-25};
+ --selection-color: #{$highlight-blue};
+
+ position: absolute;
+ top: $row-height;
+ width: calc(var(--row-width) + #{$platform-scrollbar-width});
+ max-width: calc(100% + #{$table-padding});
+ height: calc(100% - #{$row-height});
+ border: none;
+ overflow: auto !important;
+
+ &.show-expressions {
+ .index-column-header {
+ .show-hide-row-labels-button {
+ top: $row-height;
+ }
+ }
+ }
+
+ .rdg-header-row {
+ .index-column-header {
+ min-width: $index-column-width;
+ padding: 0;
+ border-left: $border-style;
+ border-top-left-radius: $border-radius;
+ .show-hide-row-labels-button {
+ width: $index-column-width;
+ height: $row-height;
+ position: relative;
+ .hide-row-labels-icon, .show-row-labels-icon {
+ position: absolute;
+ left: 4px;
+ top: 4px;
+ }
+ &.shown {
+ .hide-row-labels-icon { opacity: 35% }
+ .show-row-labels-icon { opacity: 0% }
+ }
+ &.hidden {
+ .hide-row-labels-icon { opacity: 0% }
+ .show-row-labels-icon { opacity: 35% }
+ }
+ &:hover {
+ &.shown {
+ .hide-row-labels-icon { opacity: 0% }
+ .show-row-labels-icon { opacity: 100% }
+ }
+ &.hidden {
+ .hide-row-labels-icon { opacity: 100% }
+ .show-row-labels-icon { opacity: 0% }
+ }
+ }
+ }
+ }
+ .rdg-cell {
+ border-top: $border-style;
+ &.selected-column {
+ background-color: var(--header-selected-background-color);
+ }
+ input {
+ background-color: inherit;
+ }
+ }
+ .controls-column-header {
+ background-color: white;
+ border: none;
+ padding: 0;
+ .add-column-button {
+ width: $controls-column-width;
+ height: $row-height;
+ display: none; // overridden when tile is selected
+ justify-content: center;
+ align-items: center;
+ svg {
+ fill: white;
+ stroke: $workspace-teal-light-3;
+ }
+ &:hover {
+ svg {
+ fill: $workspace-teal-light-4;
+ stroke: $workspace-teal-dark-1;
+ }
+ }
+ &:active {
+ svg {
+ fill: $workspace-teal-dark-1;
+ stroke: white;
+ }
+ }
+ }
+ }
+ }
+ .rdg-row {
+ &.rdg-row-selected {
+ background-color: var(--row-selected-background-color);
+ .index-column {
+ background-color: var(--header-selected-background-color);
+ }
+ .has-expression {
+ background-color: var(--row-selected-background-color);
+ }
+ &.input-row {
+ .index-column {
+ background-color: $highlight-blue-50-35;
+ }
+ }
+ }
+ &.input-row {
+ .index-column {
+ background-color: $header-input-color;
+ border-left: $border-input-style;
+ }
+ .rdg-cell {
+ border-right: $border-input-style;
+ border-bottom: $border-input-style;
+ }
+ .controls-column {
+ border: none;
+ }
+ }
+ .index-column {
+ border-left: $border-style;
+ }
+ .controls-column {
+ background-color: white;
+ border: none;
+ box-shadow: none;
+ padding: 0;
+ .remove-row-button {
+ width: $controls-column-width;
+ height: $row-height;
+ display: none; // overridden when tile is selected
+ justify-content: center;
+ align-items: center;
+ svg {
+ fill: white;
+ stroke: $highlight-blue;
+ }
+ &:hover {
+ svg {
+ fill: $highlight-blue-25;
+ stroke: $highlight-blue;
+ }
+ }
+ &:active {
+ svg {
+ fill: $highlight-blue;
+ stroke: white;
+ }
+ }
+ }
+ }
+ }
+ .index-column {
+ width: $index-column-width;
+ min-width: $index-column-width;
+ padding: 0;
+ background-color: var(--header-background-color);
+ font-weight: bold;
+ font-style: italic;
+ .index-cell-contents {
+ width: 100%;
+ height: 100%;
+ }
+ }
+ .rdg-cell {
+ input {
+ text-align: center;
+ }
+
+ &.has-expression {
+ background-color: $charcoal-light-5;
+ }
+ &.selected-column {
+ background-color: var(--row-selected-background-color);
+ }
+ }
+ }
+}
+
+.tool-tile.selected {
+ .table-tool {
+ .add-column-button, .remove-row-button {
+ // show buttons only when tile is selected
+ display: flex !important;
+ }
+ }
+}
+
+// .link-color-0 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-0-light
+
+// .link-color-1 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-1-light
+
+// .link-color-2 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-2-light
+
+// .link-color-3 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-3-light
+
+// .link-color-4 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-4-light
+
+// .link-color-5 > .ag-theme-fresh .ag-row-selected
+// background-color: $link-color-5-light
diff --git a/src/components/tools/table-tool/table-tool.tsx b/src/components/tools/table-tool/table-tool.tsx
index 62c182cc00..f9fd3167d1 100644
--- a/src/components/tools/table-tool/table-tool.tsx
+++ b/src/components/tools/table-tool/table-tool.tsx
@@ -1,446 +1,597 @@
-import React from "react";
-import { observer, inject } from "mobx-react";
-import { Alert, Intent } from "@blueprintjs/core";
-import { BaseComponent } from "../../base";
-import DataTableComponent, { LOCAL_ROW_ID } from "./data-table";
-import { LinkedTableCellEditor } from "./linked-table-cell-editor";
-import { IMenuItemFlags } from "./table-header-menu";
+import { observer } from "mobx-react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import ReactDataGrid from "react-data-grid";
+import { getTableContentHeight, TableContentModelType } from "../../../models/tools/table/table-content";
import { IToolTileProps } from "../tool-tile";
-import { GridApi, GridReadyEvent, SelectionChangedEvent, ValueGetterParams, ValueFormatterParams
- } from "@ag-grid-community/core";
-import { DataSet, IDataSet, ICase, ICaseCreation } from "../../../models/data/data-set";
-import { getSettingFromStores } from "../../../models/stores/stores";
-import { addTable, getLinkedTableIndex } from "../../../models/tools/table-links";
-import { canonicalizeValue, getRowLabel, isLinkableValue, ILinkProperties, ITableLinkProperties,
- TableContentModelType } from "../../../models/tools/table/table-content";
-import { getGeometryContent } from "../../../models/tools/geometry/geometry-content";
-import { JXGCoordPair, JXGProperties, JXGUnsafeCoordPair } from "../../../models/tools/geometry/jxg-changes";
-import { HotKeys } from "../../../utilities/hot-keys";
-import { uniqueId } from "../../../utilities/js-utils";
-import { format } from "d3-format";
-import { each, memoize, sortedIndexOf } from "lodash";
-import { autorun, IReactionDisposer, Lambda } from "mobx";
-type MobXDisposer = IReactionDisposer | Lambda;
-
-import "./table-tool.sass";
-
-const memoizedFormat = memoize(format);
-
-interface IClipboardCases {
- attrs: Array<{ id: string, name: string }>;
- cases: ICase[];
-}
-
-interface IState {
- dataSet: IDataSet;
- showInvalidPasteAlert?: boolean;
-}
-
-@inject("stores")
-@observer
-export default class TableToolComponent extends BaseComponent {
-
- public static tileHandlesSelection = true;
-
- public state: IState = {
- dataSet: DataSet.create()
- };
-
- private modelId: string;
- private domRef: React.RefObject = React.createRef();
- private hotKeys: HotKeys = new HotKeys();
- private syncedChanges: number;
- private disposers: MobXDisposer[];
-
- private gridApi?: GridApi;
-
- public componentDidMount() {
- this.initializeHotKeys();
- this.syncedChanges = 0;
- this.disposers = [];
-
- this.modelId = this.props.model.id;
- addTable(this.props.docId, this.modelId);
-
- if (this.domRef.current) {
- this.domRef.current.addEventListener("mousedown", this.handleMouseDown);
- }
-
- const metadata = this.getContent().metadata;
- this.props.onRegisterToolApi({
- isLinked: () => {
- return metadata.isLinked;
- },
- getLinkIndex: (index?: number) => {
- return metadata.isLinked
- ? getLinkedTableIndex(this.modelId)
- : -1;
- }
- });
-
- const { selection } = this.stores;
- this.disposers.push(selection.observe(this.props.model.id, change => {
- const rowId = change.name;
- const isSharedRowSelected = change.type === "delete"
- ? false
- : (change.newValue as any).storedValue;
- const rowNode = this.gridApi && this.gridApi.getRowNode(rowId);
- const isRowNodeSelected = rowNode ? rowNode.isSelected() : false;
- if (rowNode && (isSharedRowSelected !== isRowNodeSelected)) {
- rowNode.setSelected(isSharedRowSelected, false);
- }
- }));
-
- this.disposers.push(autorun(() => {
- const { model: { content } } = this.props;
- const tableContent = content as TableContentModelType;
- if (this.syncedChanges < tableContent.changes.length) {
- tableContent.applyChanges(this.state.dataSet, this.syncedChanges);
- this.syncedChanges = tableContent.changes.length;
- // The state updates in applyChanges aren't picked up by React, so we force a render
- this.forceUpdate();
- }
- }));
- }
-
- public componentWillUnmount() {
- if (this.domRef.current) {
- this.domRef.current.removeEventListener("mousedown", this.handleMouseDown);
- }
-
- this.props.onUnregisterToolApi();
-
- this.disposers.forEach(disposer => disposer());
-
- this.gridApi = undefined;
- }
-
- public render() {
- const { model, readOnly } = this.props;
- const content = this.getContent();
- const metadata = content.metadata;
- const itemFlags: IMenuItemFlags = {
- addAttribute: false,
- addCase: true,
- addRemoveDivider: false,
- setTableName: true,
- renameAttribute: true,
- removeAttribute: false,
- removeCases: true,
- unlinkGeometry: true
- };
- const linkIndex = getLinkedTableIndex(model.id);
- const linkClass = metadata.isLinked ? `is-linked link-color-${linkIndex}` : "";
- return (
-
-
- {this.renderInvalidPasteAlert()}
-
- );
- }
-
- private renderInvalidPasteAlert() {
- return this.state.showInvalidPasteAlert && (
-
-
- Linked data must be numeric. Please edit the table values so that all pasted cells contain numbers.
-
-
- );
- }
-
- private handleCloseInvalidPasteAlert = () => {
- this.setState({ showInvalidPasteAlert: false });
- }
-
- private getContent() {
- return this.props.model.content as TableContentModelType;
- }
-
- private initializeHotKeys() {
- this.hotKeys.register({
- "cmd-c": this.handleCopy,
- "cmd-v": this.handlePaste
+import { EditableTableTitle } from "./editable-table-title";
+import { TableToolbar } from "./table-toolbar";
+import { useColumnWidths } from "./use-column-widths";
+import { useContentChangeHandlers } from "./use-content-change-handlers";
+import { useDataSet } from "./use-data-set";
+import { useExpressionsDialog } from "./use-expressions-dialog";
+import { useGeometryLinking } from "./use-geometry-linking";
+import { useGridContext } from "./use-grid-context";
+import { useModelDataSet } from "./use-model-data-set";
+import { useRowLabelColumn } from "./use-row-label-column";
+import { useTableTitle } from "./use-table-title";
+import { useToolApi } from "./use-tool-api";
+import { useCurrent } from "../../../hooks/use-current";
+import { useMeasureText } from "../hooks/use-measure-text";
+import { useToolbarToolApi } from "../hooks/use-toolbar-tool-api";
+import { lightenColor } from "../../../utilities/color-utils";
+
+import "react-data-grid/dist/react-data-grid.css";
+import "./table-tool.scss";
+
+// observes row selection from shared selection store
+const TableToolComponent: React.FC = observer(({
+ documentId, documentContent, toolTile, model, readOnly, height, scale,
+ onRequestRowHeight, onRequestTilesOfType, onRequestUniqueTitle, onRegisterToolApi, onUnregisterToolApi
+}) => {
+ const modelRef = useCurrent(model);
+ const getContent = useCallback(() => modelRef.current.content as TableContentModelType, [modelRef]);
+ const metadata = getContent().metadata;
+
+ const {
+ dataSet, columnChanges, triggerColumnChange, rowChanges, triggerRowChange, ...gridModelProps
+ } = useModelDataSet(model);
+
+ const handleRequestUniqueTitle = useCallback(() => {
+ return onRequestUniqueTitle(modelRef.current.id);
+ }, [modelRef, onRequestUniqueTitle]);
+
+ const getContentHeight = useCallback(() => {
+ return getTableContentHeight({
+ readOnly,
+ dataRows: dataSet.current.cases.length,
+ hasExpressions: getContent().hasExpressions,
+ padding: 10 + (modelRef.current.display === "teacher" ? 20 : 0)
});
- }
-
- private handleGridReady = (gridReadyParams: GridReadyEvent) => {
- this.gridApi = gridReadyParams.api || undefined;
- }
-
- private handleRowSelectionChange = (e: SelectionChangedEvent) => {
- const { selection } = this.stores;
- const nodes = e.api.getSelectedNodes();
- selection.setSelected(this.props.model.id, nodes.map(n => n.id));
- }
-
- private handleMouseDown = (e: MouseEvent) => {
- const target: HTMLElement = e.target as HTMLElement;
- const targetClasses = target && target.className;
- // don't mess with focus if this looks like something ag-grid has handled
- if (typeof targetClasses !== "string") return;
- if (targetClasses.includes("ag-cell") || targetClasses.includes("ag-header-cell")) {
- return;
+ }, [dataSet, getContent, modelRef, readOnly]);
+
+ const heightRef = useCurrent(height);
+ const handleRequestRowHeight = useCallback((options: { height?: number, deltaHeight?: number }) => {
+ // increase row height automatically but require manual shrinking
+ if (!heightRef.current ||
+ (options?.height && (options.height > heightRef.current)) ||
+ (options?.deltaHeight && (options.deltaHeight > 0))) {
+ onRequestRowHeight(modelRef.current.id, options?.height, options?.deltaHeight);
}
-
- // table tile should have keyboard focus -- requires tabIndex
- this.domRef.current?.focus();
-
- // clicking on table background clears selection
- this.gridApi?.deselectAll();
- this.gridApi?.refreshCells();
- }
-
- private handleKeyDown = (e: React.KeyboardEvent) => {
- this.hotKeys.dispatch(e);
- }
-
- private handleCopy = () => {
- const { dataSet } = this.state;
- if (this.gridApi && dataSet) {
- const sortedRowIds = this.gridApi.getSelectedNodes().map(row => row.id).sort();
- const rowIds = dataSet.cases.map(aCase => aCase.__id__).filter(id => sortedIndexOf(sortedRowIds, id) >= 0);
- if (rowIds && rowIds.length) {
- const { clipboard } = this.stores;
- const clipData = {
- attrs: dataSet.attributes.map(attr => ({ id: attr.id, name: attr.name })),
- cases: dataSet.getCanonicalCases(rowIds)
- };
- clipboard.clear();
- clipboard.addTileContent(this.props.model.id, this.getContent().type, clipData, this.stores);
- }
+ }, [heightRef, modelRef, onRequestRowHeight]);
+
+ const changeHandlers = useContentChangeHandlers({
+ model, dataSet: dataSet.current,
+ onRequestRowHeight: handleRequestRowHeight, triggerColumnChange, triggerRowChange
+ });
+ const { onSetTableTitle, onSetColumnExpressions, onLinkGeometryTile } = changeHandlers;
+
+ const [showRowLabels, setShowRowLabels] = useState(false);
+ const {
+ ref: gridRef, gridContext, inputRowId, selectedCell, getSelectedRows, ...gridProps
+ } = useGridContext({ modelId: model.id, showRowLabels, triggerColumnChange });
+ const measureHeaderText = useMeasureText("bold 14px Lato, sans-serif");
+ // const measureBodyText = useMeasureText("14px Lato, sans-serif");
+ const { getTitle, onBeginTitleEdit, onEndTitleEdit } = useTableTitle({
+ gridContext, dataSet: dataSet.current, readOnly,
+ onSetTableTitle, onRequestUniqueTitle: handleRequestUniqueTitle
+ });
+
+ useToolApi({ metadata, getTitle, getContentHeight, onRegisterToolApi, onUnregisterToolApi });
+
+ const rowLabelProps = useRowLabelColumn({
+ inputRowId: inputRowId.current, selectedCell, showRowLabels, setShowRowLabels
+ });
+
+ const handleSubmitExpressions = (expressions: Map) => {
+ if (dataSet.current.attributes.length && expressions.size) {
+ onSetColumnExpressions(expressions, dataSet.current.attributes[0].name);
}
- }
-
- private handlePaste = () => {
- const content = this.getContent();
- const { readOnly } = this.props;
- if (!readOnly && this.gridApi) {
- const { clipboard } = this.stores;
- const clipData: IClipboardCases = clipboard.getTileContent(content.type);
- if (clipData && clipData.cases && clipData.cases.length) {
- const attrCount = Math.min(this.state.dataSet.attributes.length, clipData.attrs.length);
- const attrMap: { [id: string]: string } = {};
- clipData.attrs.forEach((attr, i) => {
- if (i < attrCount) {
- attrMap[attr.id] = this.state.dataSet.attributes[i].id;
- }
- });
- let doesTableContainUnlinkableValues = false;
- const cases = clipData.cases.map(srcCase => {
- const dstCase: ICase = { __id__: uniqueId() };
- each(srcCase, (value, attrID) => {
- const dstAttrID = attrMap[attrID];
- if (dstAttrID) {
- dstCase[dstAttrID] = value;
- if (!isLinkableValue(value)) {
- doesTableContainUnlinkableValues = true;
- }
- }
- });
- return dstCase;
- });
- if (content.isLinked && doesTableContainUnlinkableValues) {
- this.setState({ showInvalidPasteAlert: true });
- }
- else {
- this.handleAddCanonicalCases(cases);
- }
- }
+ };
+ const [showExpressionsDialog, , setCurrYAttrId] = useExpressionsDialog({
+ metadata, dataSet: dataSet.current, onSubmit: handleSubmitExpressions
+ });
+
+ const handleShowExpressionsDialog = (attrId?: string) => {
+ attrId && setCurrYAttrId(attrId);
+ showExpressionsDialog();
+ };
+ const { hasLinkableRows, ...dataGridProps } = useDataSet({
+ gridRef, gridContext, model, dataSet: dataSet.current, columnChanges, triggerColumnChange,
+ rowChanges, readOnly: !!readOnly, changeHandlers, measureText: measureHeaderText,
+ selectedCell, inputRowId, ...rowLabelProps, onShowExpressionsDialog: handleShowExpressionsDialog });
+
+ const { showLinkButton, isLinkEnabled, linkIndex, linkColors, showLinkGeometryDialog } =
+ useGeometryLinking({ documentId, model, hasLinkableRows, onRequestTilesOfType, onLinkGeometryTile });
+
+ const { titleCellWidth } =
+ useColumnWidths({ readOnly, getTitle, columns: dataGridProps.columns, measureText: measureHeaderText });
+
+ const containerRef = useRef(null);
+ const handleBackgroundClick = (e: React.MouseEvent) => {
+ // clear any selection on background click
+ (e.target === containerRef.current) && gridContext.onClearSelection();
+ };
+
+ useEffect(() => {
+ if (containerRef.current && linkColors) {
+ // override the CSS variables controlling selection color for linked tables
+ const dataGrid = containerRef.current.getElementsByClassName("rdg")[0] as HTMLDivElement | undefined;
+ dataGrid?.style.setProperty("--header-selected-background-color", lightenColor(linkColors.stroke));
+ dataGrid?.style.setProperty("--row-selected-background-color", lightenColor(linkColors.fill));
}
- }
-
- private indexValueGetter = (params: ValueGetterParams) => {
- const metadata = this.getContent().metadata;
- return metadata && metadata.isLinked && (params.data.id !== LOCAL_ROW_ID)
- ? getRowLabel(params.node.rowIndex)
- : "";
- }
-
- private attrValueFormatter = (params: ValueFormatterParams) => {
- if ((params.value == null) || (params.value === "")) return params.value;
- const num = Number(params.value);
- if (!isFinite(num)) return params.value;
- const kDefaultFormatStr = ".1~f"; // one decimal place, remove trailing zero
- const formatStr = getSettingFromStores(this.stores, "numFormat", "table") as string | undefined ||
- kDefaultFormatStr;
- return memoizedFormat(formatStr)(num);
- }
-
- private getGeometryContent(geometryId: string) {
- return getGeometryContent(this.getContent(), geometryId);
- }
-
- private getPositionOfPoint(caseId: string): JXGUnsafeCoordPair {
- const { dataSet } = this.state;
- const attrCount = dataSet.attributes.length;
- const xAttr = attrCount > 0 ? dataSet.attributes[0] : undefined;
- const yAttr = attrCount > 1 ? dataSet.attributes[1] : undefined;
- // convert non-numeric values to 0
- const xValue = xAttr ? dataSet.getValue(caseId, xAttr.id) : 0;
- const yValue = yAttr ? dataSet.getValue(caseId, yAttr.id) : 0;
- return [canonicalizeValue(xValue), canonicalizeValue(yValue)];
- }
-
- private getTableActionLinks(): ILinkProperties | undefined {
- const linkedGeometries = this.getContent().metadata.linkedGeometries;
- if (!linkedGeometries || !linkedGeometries.length) return;
- const actionId = uniqueId();
- return { id: actionId, tileIds: [...linkedGeometries] };
- }
-
- private getGeometryActionLinks(links?: ILinkProperties, addLabelMap = false): ITableLinkProperties | undefined {
- if (!links || !links.id) return;
- return this.getContent().getClientLinks(links.id, this.state.dataSet, addLabelMap);
- }
-
- private getGeometryActionLinksWithLabels(links?: ILinkProperties) {
- return this.getGeometryActionLinks(links, true);
- }
-
- private handleSetTableName = (name: string) => {
- // const shouldExpandTable = name && !this.state.dataSet?.name;
- this.getContent().setTableName(name);
- const kTableNameHeight = 25;
- this.props.onRequestRowHeight(this.props.model.id, undefined, kTableNameHeight);
- }
-
- private handleSetAttributeName = (attributeId: string, name: string) => {
- const tableActionLinks = this.getTableActionLinks();
- this.getContent().setAttributeName(attributeId, name);
- const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
- this.getContent().metadata.linkedGeometries.forEach(id => {
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.updateAxisLabels(undefined, this.props.model.id, geomActionLinks);
- }
- });
- }
-
- private handleSetExpression = (attributeId: string, expression: string, rawExpression: string) => {
- this.getContent().setExpression(attributeId, expression, rawExpression);
- const dataSet = this.state.dataSet;
- const tableActionLinks = this.getTableActionLinks();
- const geomActionLinks = this.getGeometryActionLinks(tableActionLinks);
- const ids: string[] = [];
- const props: JXGProperties[] = [];
- dataSet.cases.forEach(aCase => {
- const caseId = aCase.__id__;
- ids.push(caseId);
- const position = this.getPositionOfPoint(caseId) as JXGCoordPair;
- props.push({ position });
- });
- this.getContent().metadata.linkedGeometries.forEach(id => {
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.updateObjects(undefined, ids, props, geomActionLinks);
- }
- });
- }
-
- private handleAddCanonicalCases = (newCases: ICaseCreation[]) => {
- const validateCase = (aCase: ICaseCreation) => {
- const newCase: ICaseCreation = { __id__: uniqueId() };
- if (this.getContent().isLinked) {
- // validate linkable values
- this.state.dataSet.attributes.forEach(attr => {
- const value = aCase[attr.id];
- newCase[attr.id] = isLinkableValue(value) ? value : 0;
- });
- return newCase;
- }
- return { ...newCase, ...aCase };
- };
- const cases = newCases.map(aCase => validateCase(aCase));
- const selectedRowIds = this.gridApi && this.gridApi.getSelectedNodes().map(row => row.id);
- const firstSelectedRowId = selectedRowIds && selectedRowIds.length && selectedRowIds[0] || undefined;
- const tableActionLinks = this.getTableActionLinks();
- this.getContent().addCanonicalCases(cases, firstSelectedRowId, tableActionLinks);
- const parents = cases.map(aCase => this.getPositionOfPoint(aCase.__id__ as string));
- const props = cases.map(aCase => ({ id: aCase.__id__ }));
- const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
- this.getContent().metadata.linkedGeometries.forEach(id => {
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.addPoints(undefined, parents, props, geomActionLinks);
- }
- });
- }
-
- private handleSetCanonicalCaseValues = (caseValues: ICase) => {
- const caseId = caseValues.__id__;
- const tableActionLinks = this.getTableActionLinks();
- this.getContent().setCanonicalCaseValues([caseValues], tableActionLinks);
- const geomActionLinks = this.getGeometryActionLinks(tableActionLinks);
- this.getContent().metadata.linkedGeometries.forEach(id => {
- const newPosition = this.getPositionOfPoint(caseId);
- const position = newPosition as JXGCoordPair;
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.updateObjects(undefined, caseId, { position }, geomActionLinks);
- }
- });
- }
-
- private handleRemoveCases = (ids: string[]) => {
- const tableActionLinks = this.getTableActionLinks();
- this.getContent().removeCases(ids, tableActionLinks);
- const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
- this.getContent().metadata.linkedGeometries.forEach(id => {
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.removeObjects(undefined, ids, geomActionLinks);
- }
- });
- }
-
- private handleGetLinkedGeometries = () => {
- return this.getContent().metadata.linkedGeometries.toJS();
- }
-
- private handleUnlinkGeometry = () => {
- const geometryIds = this.getContent().metadata.linkedGeometries.toJS();
- const tableActionLinks = this.getTableActionLinks();
- this.getContent().removeGeometryLinks(geometryIds, tableActionLinks);
- const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
- geometryIds.forEach(id => {
- const geometryContent = this.getGeometryContent(id);
- if (geometryContent) {
- geometryContent.removeTableLink(undefined, this.props.model.id, geomActionLinks);
- }
- });
- }
-}
+ });
+
+ const toolbarProps = useToolbarToolApi({ id: model.id, enabled: !readOnly, onRegisterToolApi, onUnregisterToolApi });
+ return (
+
+ );
+});
+export default TableToolComponent;
+(TableToolComponent as any).tileHandlesSelection = true;
+
+// import { observer, inject } from "mobx-react";
+// import { Alert, Intent } from "@blueprintjs/core";
+// import { BaseComponent } from "../../base";
+// import DataTableComponent, { LOCAL_ROW_ID } from "./data-table";
+// import { LinkedTableCellEditor } from "./linked-table-cell-editor";
+// import { IMenuItemFlags } from "./table-header-menu";
+// import { IToolTileProps } from "../tool-tile";
+// import { GridApi, GridReadyEvent, SelectionChangedEvent, ValueGetterParams, ValueFormatterParams
+// } from "@ag-grid-community/core";
+// import { DataSet, IDataSet, ICase, ICaseCreation } from "../../../models/data/data-set";
+// import { getSettingFromStores } from "../../../models/stores/stores";
+// import { addTable, getLinkedTableIndex } from "../../../models/tools/table-links";
+// import { canonicalizeValue, getRowLabel, isLinkableValue, ILinkProperties, ITableLinkProperties,
+// TableContentModelType } from "../../../models/tools/table/table-content";
+// import { getGeometryContent } from "../../../models/tools/geometry/geometry-content";
+// import { JXGCoordPair, JXGProperties, JXGUnsafeCoordPair } from "../../../models/tools/geometry/jxg-changes";
+// import { HotKeys } from "../../../utilities/hot-keys";
+// import { uniqueId } from "../../../utilities/js-utils";
+// import { format } from "d3-format";
+// import { each, memoize, sortedIndexOf } from "lodash";
+// import { autorun, IReactionDisposer, Lambda } from "mobx";
+// type MobXDisposer = IReactionDisposer | Lambda;
+
+// import "./table-tool.sass";
+
+// const memoizedFormat = memoize(format);
+
+// interface IClipboardCases {
+// attrs: Array<{ id: string, name: string }>;
+// cases: ICase[];
+// }
+
+// interface IState {
+// dataSet: IDataSet;
+// showInvalidPasteAlert?: boolean; [TODO]
+// }
+
+// @inject("stores")
+// @observer
+// export default class TableToolComponent extends BaseComponent {
+
+// public static tileHandlesSelection = true;
+
+// public state: IState = {
+// dataSet: DataSet.create()
+// };
+
+// private modelId: string;
+// private domRef: React.RefObject = React.createRef();
+// private hotKeys: HotKeys = new HotKeys();
+// private syncedChanges: number;
+// private disposers: MobXDisposer[];
+
+// private gridApi?: GridApi;
+
+// public componentDidMount() {
+// this.initializeHotKeys(); [TODO]
+// this.syncedChanges = 0;
+// this.disposers = [];
+
+// this.modelId = this.props.model.id;
+// addTable(this.props.docId, this.modelId);
+
+// if (this.domRef.current) {
+// this.domRef.current.addEventListener("mousedown", this.handleMouseDown);
+// }
+
+// const metadata = this.getContent().metadata;
+// this.props.onRegisterToolApi({
+// isLinked: () => {
+// return metadata.isLinked;
+// },
+// getLinkIndex: (index?: number) => {
+// return metadata.isLinked
+// ? getLinkedTableIndex(this.modelId)
+// : -1;
+// }
+// });
+
+// const { selection } = this.stores;
+// this.disposers.push(selection.observe(this.props.model.id, change => { [TODO]
+// const rowId = change.name;
+// const isSharedRowSelected = change.type === "delete"
+// ? false
+// : (change.newValue as any).storedValue;
+// const rowNode = this.gridApi && this.gridApi.getRowNode(rowId);
+// const isRowNodeSelected = rowNode ? rowNode.isSelected() : false;
+// if (rowNode && (isSharedRowSelected !== isRowNodeSelected)) {
+// rowNode.setSelected(isSharedRowSelected, false);
+// }
+// }));
+
+// this.disposers.push(autorun(() => {
+// const { model: { content } } = this.props;
+// const tableContent = content as TableContentModelType;
+// if (this.syncedChanges < tableContent.changes.length) {
+// tableContent.applyChanges(this.state.dataSet, this.syncedChanges);
+// this.syncedChanges = tableContent.changes.length;
+// // The state updates in applyChanges aren't picked up by React, so we force a render
+// this.forceUpdate();
+// }
+// }));
+// }
+
+// public componentWillUnmount() {
+// if (this.domRef.current) {
+// this.domRef.current.removeEventListener("mousedown", this.handleMouseDown);
+// }
+
+// this.props.onUnregisterToolApi();
+
+// this.disposers.forEach(disposer => disposer());
+
+// this.gridApi = undefined;
+// }
+
+// public render() {
+// const { model, readOnly } = this.props;
+// const content = this.getContent();
+// const metadata = content.metadata;
+// const itemFlags: IMenuItemFlags = {
+// addAttribute: false,
+// addCase: true,
+// addRemoveDivider: false,
+// setTableName: true,
+// renameAttribute: true,
+// removeAttribute: false,
+// removeCases: true,
+// unlinkGeometry: true
+// };
+// const linkIndex = getLinkedTableIndex(model.id);
+// const linkClass = metadata.isLinked ? `is-linked link-color-${linkIndex}` : "";
+// return (
+//
+//
+// {this.renderInvalidPasteAlert()}
+//
+// );
+// }
+
+// private renderInvalidPasteAlert() { [TODO]
+// return this.state.showInvalidPasteAlert && (
+//
+//
+// Linked data must be numeric. Please edit the table values so that all pasted cells contain numbers.
+//
+//
+// );
+// }
+
+// private handleCloseInvalidPasteAlert = () => {
+// this.setState({ showInvalidPasteAlert: false });
+// }
+
+// private getContent() {
+// return this.props.model.content as TableContentModelType;
+// }
+
+// private initializeHotKeys() { [TODO]
+// this.hotKeys.register({
+// "cmd-c": this.handleCopy,
+// "cmd-v": this.handlePaste
+// });
+// }
+
+// private handleGridReady = (gridReadyParams: GridReadyEvent) => {
+// this.gridApi = gridReadyParams.api || undefined;
+// }
+
+// private handleRowSelectionChange = (e: SelectionChangedEvent) => { [TODO]
+// const { selection } = this.stores;
+// const nodes = e.api.getSelectedNodes();
+// selection.setSelected(this.props.model.id, nodes.map(n => n.id)); [TODO] sync row selection back to model
+// }
+
+// private handleMouseDown = (e: MouseEvent) => { [TODO]
+// const target: HTMLElement = e.target as HTMLElement;
+// const targetClasses = target && target.className;
+// // don't mess with focus if this looks like something ag-grid has handled
+// if (typeof targetClasses !== "string") return;
+// if (targetClasses.includes("ag-cell") || targetClasses.includes("ag-header-cell")) {
+// return;
+// }
+
+// // table tile should have keyboard focus -- requires tabIndex
+// this.domRef.current?.focus(); [TODO?]
+
+// // clicking on table background clears selection [TODO]
+// this.gridApi?.deselectAll();
+// this.gridApi?.refreshCells();
+// }
+
+// private handleKeyDown = (e: React.KeyboardEvent) => { [TODO]
+// this.hotKeys.dispatch(e);
+// }
+
+// private handleCopy = () => { [TODO]
+// const { dataSet } = this.state;
+// if (this.gridApi && dataSet) {
+// const sortedRowIds = this.gridApi.getSelectedNodes().map(row => row.id).sort();
+// const rowIds = dataSet.cases.map(aCase => aCase.__id__).filter(id => sortedIndexOf(sortedRowIds, id) >= 0);
+// if (rowIds && rowIds.length) {
+// const { clipboard } = this.stores;
+// const clipData = {
+// attrs: dataSet.attributes.map(attr => ({ id: attr.id, name: attr.name })),
+// cases: dataSet.getCanonicalCases(rowIds)
+// };
+// clipboard.clear();
+// clipboard.addTileContent(this.props.model.id, this.getContent().type, clipData, this.stores);
+// }
+// }
+// }
+
+// private handlePaste = () => { [TODO]
+// const content = this.getContent();
+// const { readOnly } = this.props;
+// if (!readOnly && this.gridApi) {
+// const { clipboard } = this.stores;
+// const clipData: IClipboardCases = clipboard.getTileContent(content.type);
+// if (clipData && clipData.cases && clipData.cases.length) {
+// const attrCount = Math.min(this.state.dataSet.attributes.length, clipData.attrs.length);
+// const attrMap: { [id: string]: string } = {};
+// clipData.attrs.forEach((attr, i) => {
+// if (i < attrCount) {
+// attrMap[attr.id] = this.state.dataSet.attributes[i].id;
+// }
+// });
+// let doesTableContainUnlinkableValues = false;
+// const cases = clipData.cases.map(srcCase => {
+// const dstCase: ICase = { __id__: uniqueId() };
+// each(srcCase, (value, attrID) => {
+// const dstAttrID = attrMap[attrID];
+// if (dstAttrID) {
+// dstCase[dstAttrID] = value;
+// if (!isLinkableValue(value)) {
+// doesTableContainUnlinkableValues = true;
+// }
+// }
+// });
+// return dstCase;
+// });
+// if (content.isLinked && doesTableContainUnlinkableValues) {
+// this.setState({ showInvalidPasteAlert: true });
+// }
+// else {
+// this.handleAddCanonicalCases(cases);
+// }
+// }
+// }
+// }
+
+// private indexValueGetter = (params: ValueGetterParams) => {
+// const metadata = this.getContent().metadata;
+// return metadata && metadata.isLinked && (params.data.id !== LOCAL_ROW_ID)
+// ? getRowLabel(params.node.rowIndex)
+// : "";
+// }
+
+// private attrValueFormatter = (params: ValueFormatterParams) => {
+// if ((params.value == null) || (params.value === "")) return params.value;
+// const num = Number(params.value);
+// if (!isFinite(num)) return params.value;
+// const kDefaultFormatStr = ".1~f"; // one decimal place, remove trailing zero
+// const formatStr = getSettingFromStores(this.stores, "numFormat", "table") as string | undefined ||
+// kDefaultFormatStr;
+// return memoizedFormat(formatStr)(num);
+// }
+
+// private getGeometryContent(geometryId: string) {
+// return getGeometryContent(this.getContent(), geometryId);
+// }
+
+// => useContentChangeHandlers [getPositionOfPoint]
+// private getPositionOfPoint(caseId: string): JXGUnsafeCoordPair {
+// const { dataSet } = this.state;
+// const attrCount = dataSet.attributes.length;
+// const xAttr = attrCount > 0 ? dataSet.attributes[0] : undefined;
+// const yAttr = attrCount > 1 ? dataSet.attributes[1] : undefined;
+// // convert non-numeric values to 0
+// const xValue = xAttr ? dataSet.getValue(caseId, xAttr.id) : 0;
+// const yValue = yAttr ? dataSet.getValue(caseId, yAttr.id) : 0;
+// return [canonicalizeValue(xValue), canonicalizeValue(yValue)];
+// }
+
+// => useContentChangeHandlers [getTableActionLinks]
+// private getTableActionLinks(): ILinkProperties | undefined {
+// const linkedGeometries = this.getContent().metadata.linkedGeometries;
+// if (!linkedGeometries || !linkedGeometries.length) return;
+// const actionId = uniqueId();
+// return { id: actionId, tileIds: [...linkedGeometries] };
+// }
+
+// => useContentChangeHandlers [getGeometryActionLinks]
+// private getGeometryActionLinks(links?: ILinkProperties, addLabelMap = false): ITableLinkProperties | undefined {
+// if (!links || !links.id) return;
+// return this.getContent().getClientLinks(links.id, this.state.dataSet, addLabelMap);
+// }
+
+// => useContentChangeHandlers [getGeometryActionLinksWithLabels]
+// private getGeometryActionLinksWithLabels(links?: ILinkProperties) {
+// return this.getGeometryActionLinks(links, true);
+// }
+
+// => useContentChangeHandlers [setTableName]
+// private handleSetTableName = (name: string) => {
+// // const shouldExpandTable = name && !this.state.dataSet?.name;
+// this.getContent().setTableName(name);
+// const kTableNameHeight = 25;
+// this.props.onRequestRowHeight(this.props.model.id, undefined, kTableNameHeight);
+// }
+
+// => useContentChangeHandlers [setColumnName]
+// private handleSetAttributeName = (attributeId: string, name: string) => {
+// const tableActionLinks = this.getTableActionLinks();
+// this.getContent().setAttributeName(attributeId, name);
+// const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
+// this.getContent().metadata.linkedGeometries.forEach(id => {
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.updateAxisLabels(undefined, this.props.model.id, geomActionLinks);
+// }
+// });
+// }
+
+// => useContentChangeHandlers [setColumnExpressions]
+// private handleSetExpression = (attributeId: string, expression: string, rawExpression: string) => {
+// this.getContent().setExpression(attributeId, expression, rawExpression);
+// const dataSet = this.state.dataSet;
+// const tableActionLinks = this.getTableActionLinks();
+// const geomActionLinks = this.getGeometryActionLinks(tableActionLinks);
+// const ids: string[] = [];
+// const props: JXGProperties[] = [];
+// dataSet.cases.forEach(aCase => {
+// const caseId = aCase.__id__;
+// ids.push(caseId);
+// const position = this.getPositionOfPoint(caseId) as JXGCoordPair;
+// props.push({ position });
+// });
+// this.getContent().metadata.linkedGeometries.forEach(id => {
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.updateObjects(undefined, ids, props, geomActionLinks);
+// }
+// });
+// }
+
+// => useContentChangeHandlers [addRows]
+// private handleAddCanonicalCases = (newCases: ICaseCreation[]) => {
+// const validateCase = (aCase: ICaseCreation) => {
+// const newCase: ICaseCreation = { __id__: uniqueId() };
+// if (this.getContent().isLinked) {
+// // validate linkable values
+// this.state.dataSet.attributes.forEach(attr => {
+// const value = aCase[attr.id];
+// newCase[attr.id] = isLinkableValue(value) ? value : 0;
+// });
+// return newCase;
+// }
+// return { ...newCase, ...aCase };
+// };
+// const cases = newCases.map(aCase => validateCase(aCase));
+// const selectedRowIds = this.gridApi && this.gridApi.getSelectedNodes().map(row => row.id);
+// const firstSelectedRowId = selectedRowIds && selectedRowIds.length && selectedRowIds[0] || undefined;
+// const tableActionLinks = this.getTableActionLinks();
+// this.getContent().addCanonicalCases(cases, firstSelectedRowId, tableActionLinks);
+// const parents = cases.map(aCase => this.getPositionOfPoint(aCase.__id__ as string));
+// const props = cases.map(aCase => ({ id: aCase.__id__ }));
+// const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
+// this.getContent().metadata.linkedGeometries.forEach(id => {
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.addPoints(undefined, parents, props, geomActionLinks);
+// }
+// });
+// }
+
+// => useContentChangeHandlers [updateRow]
+// private handleSetCanonicalCaseValues = (caseValues: ICase) => {
+// const caseId = caseValues.__id__;
+// const tableActionLinks = this.getTableActionLinks();
+// this.getContent().setCanonicalCaseValues([caseValues], tableActionLinks);
+// const geomActionLinks = this.getGeometryActionLinks(tableActionLinks);
+// this.getContent().metadata.linkedGeometries.forEach(id => {
+// const newPosition = this.getPositionOfPoint(caseId);
+// const position = newPosition as JXGCoordPair;
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.updateObjects(undefined, caseId, { position }, geomActionLinks);
+// }
+// });
+// }
+
+// => useContentChangeHandlers [removeRows]
+// private handleRemoveCases = (ids: string[]) => {
+// const tableActionLinks = this.getTableActionLinks();
+// this.getContent().removeCases(ids, tableActionLinks);
+// const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
+// this.getContent().metadata.linkedGeometries.forEach(id => {
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.removeObjects(undefined, ids, geomActionLinks);
+// }
+// });
+// }
+
+// private handleGetLinkedGeometries = () => { [TODO?]
+// return this.getContent().metadata.linkedGeometries.toJS();
+// }
+
+// private handleUnlinkGeometry = () => { [TODO]
+// const geometryIds = this.getContent().metadata.linkedGeometries.toJS();
+// const tableActionLinks = this.getTableActionLinks();
+// this.getContent().removeGeometryLinks(geometryIds, tableActionLinks);
+// const geomActionLinks = this.getGeometryActionLinksWithLabels(tableActionLinks);
+// geometryIds.forEach(id => {
+// const geometryContent = this.getGeometryContent(id);
+// if (geometryContent) {
+// geometryContent.removeTableLink(undefined, this.props.model.id, geomActionLinks);
+// }
+// });
+// }
+// }
diff --git a/src/components/tools/table-tool/table-toolbar.scss b/src/components/tools/table-tool/table-toolbar.scss
new file mode 100644
index 0000000000..8c7d442833
--- /dev/null
+++ b/src/components/tools/table-tool/table-toolbar.scss
@@ -0,0 +1,35 @@
+@import "../../vars.sass";
+
+.table-toolbar {
+ position: absolute;
+ border: $toolbar-border;
+ border-radius: 0 0 $toolbar-border-radius $toolbar-border-radius;
+ z-index: $toolbar-z-index;
+
+ &.disabled {
+ display: none;
+ }
+
+ .toolbar-button {
+ position: relative;
+ width: $toolbar-button-width;
+ height: $toolbar-button-height;
+ background-color: $workspace-teal-light-9;
+
+ &:hover {
+ background-color: $workspace-teal-light-6;
+ }
+
+ &:active {
+ background-color: $workspace-teal-light-4;
+ }
+ }
+
+ .toolbar-button:first-child {
+ border-bottom-left-radius: 3px;
+ }
+
+ .toolbar-button:last-child {
+ border-bottom-right-radius: 3px;
+ }
+}
diff --git a/src/components/tools/table-tool/table-toolbar.tsx b/src/components/tools/table-tool/table-toolbar.tsx
new file mode 100644
index 0000000000..fcabd974e5
--- /dev/null
+++ b/src/components/tools/table-tool/table-toolbar.tsx
@@ -0,0 +1,46 @@
+import { observer } from "mobx-react";
+import React from "react";
+import ReactDOM from "react-dom";
+import { Tooltip } from "react-tippy";
+import SetExpressionIconSvg from "../../../clue/assets/icons/table/set-expression-icon.svg";
+import { useTooltipOptions } from "../../../hooks/use-tooltip-options";
+import { IFloatingToolbarProps, useFloatingToolbarLocation } from "../hooks/use-floating-toolbar-location";
+
+import "./table-toolbar.scss";
+
+interface ISetExpressionButtonProps {
+ onClick: () => void;
+}
+const SetExpressionButton: React.FC = ({ onClick }) => {
+ const tooltipOptions = useTooltipOptions({ title: "Set expression", distance: -34, offset: -19 });
+ return (
+
+
+
+
+
+ );
+};
+
+interface IProps extends IFloatingToolbarProps {
+ onSetExpression: () => void;
+}
+export const TableToolbar: React.FC = observer(({
+ documentContent, onIsEnabled, onSetExpression, ...others
+}) => {
+ const enabled = onIsEnabled();
+ const location = useFloatingToolbarLocation({
+ documentContent,
+ toolbarHeight: 38,
+ toolbarTopOffset: 2,
+ enabled,
+ ...others
+ });
+ return documentContent
+ ? ReactDOM.createPortal(
+ e.stopPropagation()}>
+
+
, documentContent)
+ : null;
+});
diff --git a/src/components/tools/table-tool/table-types.ts b/src/components/tools/table-tool/table-types.ts
new file mode 100644
index 0000000000..c6460cec5e
--- /dev/null
+++ b/src/components/tools/table-tool/table-types.ts
@@ -0,0 +1,52 @@
+import { Column, FormatterProps, HeaderRendererProps } from "react-data-grid";
+
+export const kRowHeight = 34;
+export const kIndexColumnWidth = 34;
+export const kControlsColumnWidth = 36;
+export const kHeaderCellPadding = 72; // half on either side of text
+export const kExpressionCellPadding = 20;
+export interface IGridContext {
+ showRowLabels: boolean;
+ isColumnSelected: (columnId: string) => boolean;
+ onSelectColumn: (columnId: string) => void;
+ isSelectedCellInRow: (rowIdx: number) => boolean;
+ onSelectRowById: (rowId: string, select: boolean) => void;
+ onSelectOneRow: (row: string) => void;
+ onClearSelection: (options?: { row?: boolean, column?: boolean, cell?: boolean }) => void;
+}
+
+export const kIndexColumnKey = "__index__";
+export const kControlsColumnKey = "__controls__";
+export interface TRow extends Record {
+ __id__: string;
+ __index__?: number;
+ __context__: IGridContext;
+}
+
+export interface TColumnAppData {
+ readOnly?: boolean;
+ gridContext: IGridContext;
+ editableName: boolean;
+ isEditing: boolean;
+ isRemovable: boolean;
+ showExpressions: boolean;
+ expression?: string;
+ onBeginHeaderCellEdit: () => boolean | undefined;
+ onHeaderCellEditKeyDown: (e: React.KeyboardEvent) => void;
+ onEndHeaderCellEdit: (value?: string) => void;
+ onShowExpressionsDialog?: (attrId?: string) => void;
+ onRemoveColumn?: (attrId: string) => void;
+ onBeginBodyCellEdit?: () => boolean | undefined;
+ onEndBodyCellEdit?: (value?: string) => void;
+}
+export interface TColumn extends Column {
+ appData?: TColumnAppData;
+}
+export interface TPosition { idx: number, rowIdx: number }
+export type TFormatterProps = FormatterProps;
+export type THeaderRendererProps = HeaderRendererProps;
+export type OnRowSelectionChangeFn = (checked: boolean, isShiftClick: boolean) => void;
+
+export const isDataColumn = (column: TColumn) => {
+ return (column.key !== kIndexColumnKey) && (column.key !== kControlsColumnKey);
+};
diff --git a/src/components/tools/table-tool/update-expression-dialog.sass b/src/components/tools/table-tool/update-expression-dialog.sass
deleted file mode 100644
index ee94d97a8b..0000000000
--- a/src/components/tools/table-tool/update-expression-dialog.sass
+++ /dev/null
@@ -1,8 +0,0 @@
-.bp3-dialog.update-expression-dialog
- width: 540px
-
- .nc-dialog-error
- height: 56px
-
- .nc-dialog-buttons
- margin: 1em 0 0 0
\ No newline at end of file
diff --git a/src/components/tools/table-tool/update-expression-dialog.tsx b/src/components/tools/table-tool/update-expression-dialog.tsx
deleted file mode 100644
index 3cffad178a..0000000000
--- a/src/components/tools/table-tool/update-expression-dialog.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import React from "react";
-import { Button, Dialog } from "@blueprintjs/core";
-import { Parser } from "expr-eval";
-
-import "./update-expression-dialog.sass";
-
-interface IProps {
- id: string;
- isOpen: boolean;
- onUpdateExpression: (id: string, expression: string, rawExpression: string) => void;
- onClose: () => void;
- expression: string;
- rawExpression: string;
- xName?: string;
- yName?: string;
-}
-
-interface IState {
- expression: string;
-}
-
-export const kSerializedXKey = "__x__";
-
-export default class UpdateExpressionDialog extends React.Component {
-
- constructor(props: IProps) {
- super(props);
- this.state = {
- expression: this.props.rawExpression || this.prettifyExpression(this.props.expression) || ""
- };
- }
-
- public render() {
- const { xName, yName } = this.props;
- const prompt = `Enter an expression for "${yName}" in terms of "${xName}"`;
- const errorMessage = this.getValidationError();
- return (
-
- );
- }
-
- private getValidationError = () => {
- const { xName } = this.props;
- const expressionStr = this.state.expression;
- if (!expressionStr) return;
- const parser = new Parser();
- try {
- const expression = parser.parse(expressionStr);
- const unknownVar = expression.variables().find(variable => variable !== xName);
- if (unknownVar) {
- return `Unrecognized variable "${unknownVar}" in expression.`;
- }
- if (xName) {
- // Attempt an evaluation to check for errors e.g. invalid function names
- expression.evaluate({[xName]: 1});
- }
- } catch {
- return "Could not understand expression. Make sure you supply all operands " +
- "and use a multiplication sign where necessary, e.g. 3 * x + 4 instead of 3x + 4.";
- }
- }
-
- private handleExpressionChange = (evt: React.FormEvent) => {
- this.setState({ expression: (evt.target as HTMLInputElement).value });
- }
-
- private handleClearExpression = () => {
- if (this.props.onUpdateExpression) {
- this.props.onUpdateExpression(this.props.id, "", "");
- }
- }
-
- private handleSubmitExpression = () => {
- if (this.props.onUpdateExpression && !this.getValidationError()) {
- // Store a canonical version of the expression so it does not need to change on column renames
- const canonicalExpression = this.canonicalizeExpression(this.state.expression);
- this.props.onUpdateExpression(this.props.id, canonicalExpression, this.state.expression);
- }
- }
-
- private handleKeyDown = (evt: React.KeyboardEvent) => {
- if (evt.keyCode === 13) {
- this.handleSubmitExpression();
- }
- }
-
- private canonicalizeExpression = (displayExpression: string) => {
- const { xName } = this.props;
- if (xName && displayExpression) {
- const parser = new Parser();
- const subbedExpression = parser.parse(displayExpression).substitute(xName, kSerializedXKey);
- return subbedExpression.toString();
- } else {
- return displayExpression;
- }
- }
-
- private prettifyExpression = (canonicalExpression: string) => {
- const { xName } = this.props;
- if (xName && canonicalExpression) {
- const parser = new Parser();
- let expression = parser.parse(canonicalExpression).substitute(kSerializedXKey, xName).toString();
- if (expression.charAt(0) === "(" && expression.charAt(expression.length - 1) === ")") {
- expression = expression.substring(1, expression.length - 1);
- }
- return expression;
- } else {
- return canonicalExpression;
- }
- }
-
-}
diff --git a/src/components/tools/table-tool/use-column-extensions.ts b/src/components/tools/table-tool/use-column-extensions.ts
new file mode 100644
index 0000000000..cb9fda2cf3
--- /dev/null
+++ b/src/components/tools/table-tool/use-column-extensions.ts
@@ -0,0 +1,65 @@
+import { TableMetadataModelType } from "../../../models/tools/table/table-content";
+import { getEditableExpression } from "./expression-utils";
+import { IGridContext, isDataColumn, TColumn } from "./table-types";
+import { IContentChangeHandlers } from "./use-content-change-handlers";
+
+interface IProps {
+ gridContext: IGridContext;
+ metadata: TableMetadataModelType;
+ readOnly?: boolean;
+ columns: TColumn[];
+ columnEditingName?: string;
+ setColumnEditingName: (column?: TColumn) => void;
+ onShowExpressionsDialog?: (attrId?: string) => void;
+ changeHandlers: IContentChangeHandlers;
+}
+export const useColumnExtensions = ({
+ gridContext, metadata, readOnly, columns, columnEditingName,
+ setColumnEditingName, onShowExpressionsDialog, changeHandlers
+}: IProps) => {
+ const { onSetColumnName, onRemoveColumn } = changeHandlers;
+ const firstDataColumn = columns.find(col => isDataColumn(col));
+ const xName = (firstDataColumn?.name || "") as string;
+
+ columns.forEach((column, i) => {
+ column.appData = {
+ readOnly,
+ gridContext,
+ editableName: !readOnly && isDataColumn(column),
+ isEditing: column.key === columnEditingName,
+ isRemovable: !readOnly && isDataColumn(column) && (column.key !== firstDataColumn?.key),
+ showExpressions: metadata.hasExpressions,
+ expression: getEditableExpression(
+ metadata.rawExpressions.get(column.key),
+ metadata.expressions.get(column.key) || "",
+ xName),
+ onBeginHeaderCellEdit: (() => {
+ gridContext.onClearSelection();
+ !readOnly && setColumnEditingName(column);
+ }) as any,
+ onHeaderCellEditKeyDown: (e: React.KeyboardEvent) => {
+ switch (e.key) {
+ case "Tab": {
+ const nextColumnToEdit = !e.shiftKey
+ ? (i < columns.length - 1 ? columns[i + 1] : undefined)
+ : (i > 1 ? columns[i - 1] : undefined);
+ !readOnly && nextColumnToEdit &&
+ setTimeout(() => setColumnEditingName(nextColumnToEdit));
+ break;
+ }
+ case "Enter":
+ break;
+ }
+ },
+ onEndHeaderCellEdit: (value?: string) => {
+ !readOnly && !!value && (value !== column.name) && onSetColumnName(column, value);
+ setColumnEditingName();
+ },
+ onRemoveColumn,
+ onShowExpressionsDialog,
+ onBeginBodyCellEdit: (() => {
+ gridContext.onClearSelection({ cell: false });
+ }) as any
+ };
+ });
+};
diff --git a/src/components/tools/table-tool/use-column-widths.ts b/src/components/tools/table-tool/use-column-widths.ts
new file mode 100644
index 0000000000..8d06a7b080
--- /dev/null
+++ b/src/components/tools/table-tool/use-column-widths.ts
@@ -0,0 +1,36 @@
+import { isDataColumn, kControlsColumnWidth, kHeaderCellPadding, TColumn } from "./table-types";
+
+interface IProps {
+ readOnly?: boolean;
+ getTitle: () => string | undefined;
+ columns: TColumn[];
+ measureText: (text: string) => number;
+}
+export const useColumnWidths = ({ readOnly, getTitle, columns, measureText }: IProps) => {
+
+ const desiredTitleCellWidth = measureText(getTitle() || "Table 8") + kHeaderCellPadding;
+
+ const kDefaultWidth = 80;
+ const columnWidth = (column: TColumn) => {
+ return Math.max(+(column.width || kDefaultWidth), column.maxWidth || kDefaultWidth);
+ };
+ const getTitleCellWidthFromColumns = () => {
+ return columns.reduce(
+ (sum, col, i) => sum + (i ? columnWidth(col) : 0),
+ 1 - (readOnly ? 0 : kControlsColumnWidth));
+ };
+
+ const titleCellWidthFromColumns = getTitleCellWidthFromColumns();
+ let titleCellWidth = titleCellWidthFromColumns;
+ // if necessary, increase width of columns to accommodate longer titles
+ if (desiredTitleCellWidth > titleCellWidthFromColumns) {
+ // distribute the additional width equally among the data columns
+ const dataColumnCount = columns.reduce((count, col) => count + (isDataColumn(col) ? 1 : 0), 0);
+ const widthAdjustment = (desiredTitleCellWidth - titleCellWidthFromColumns) / dataColumnCount;
+ const roundedWidthAdjustment = Math.ceil(10 * widthAdjustment) / 10;
+ columns.forEach((col, i) => isDataColumn(col) && (col.width = columnWidth(col) + roundedWidthAdjustment));
+ titleCellWidth = getTitleCellWidthFromColumns();
+ }
+
+ return { columns, titleCellWidth };
+};
diff --git a/src/components/tools/table-tool/use-columns-from-data-set.ts b/src/components/tools/table-tool/use-columns-from-data-set.ts
new file mode 100644
index 0000000000..6a3e430b1e
--- /dev/null
+++ b/src/components/tools/table-tool/use-columns-from-data-set.ts
@@ -0,0 +1,129 @@
+import classNames from "classnames";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { IAttribute } from "../../../models/data/attribute";
+import { IDataSet } from "../../../models/data/data-set";
+import { TableMetadataModelType } from "../../../models/tools/table/table-content";
+import { CellFormatter } from "./cell-formatter";
+import CellTextEditor from "./cell-text-editor";
+import { ColumnHeaderCell } from "./column-header-cell";
+import { prettifyExpression } from "./expression-utils";
+import {
+ IGridContext, kControlsColumnKey, kControlsColumnWidth, kExpressionCellPadding, kHeaderCellPadding,
+ kIndexColumnKey, kIndexColumnWidth, TColumn
+} from "./table-types";
+import { useColumnExtensions } from "./use-column-extensions";
+import { IContentChangeHandlers } from "./use-content-change-handlers";
+import { useControlsColumn } from "./use-controls-column";
+
+interface IUseColumnsFromDataSet {
+ gridContext: IGridContext;
+ dataSet: IDataSet;
+ metadata: TableMetadataModelType;
+ readOnly?: boolean;
+ columnChanges: number;
+ RowLabelHeader: React.FC;
+ RowLabelFormatter: React.FC;
+ measureText: (text: string) => number;
+ onShowExpressionsDialog?: (attrId?: string) => void;
+ changeHandlers: IContentChangeHandlers;
+}
+export const useColumnsFromDataSet = ({
+ gridContext, dataSet, metadata, readOnly, columnChanges, RowLabelHeader, RowLabelFormatter,
+ measureText, onShowExpressionsDialog, changeHandlers
+}: IUseColumnsFromDataSet) => {
+ const { attributes } = dataSet;
+ const { onAddColumn, onRemoveRows } = changeHandlers;
+ const onRemoveRow = useCallback((rowId: string) => onRemoveRows([rowId]), [onRemoveRows]);
+ const { ControlsHeaderRenderer, ControlsRowFormatter } = useControlsColumn({ readOnly, onAddColumn, onRemoveRow });
+ // user-modified column widths aren't currently saved
+ const userColumnWidths = useRef>({});
+ const nameColumnWidths = useRef>({});
+ const exprColumnWidths = useRef>({});
+
+ const [columnEditingName, setColumnEditingName] = useState();
+ const handleSetColumnEditingName = (column?: TColumn) => {
+ setColumnEditingName(column?.key);
+ };
+
+ const cellClasses = useCallback((attrId: string) => {
+ const selectedColumnClass = { "selected-column": gridContext.isColumnSelected(attrId) };
+ return {
+ cellClass: classNames({ "has-expression": metadata.hasExpression(attrId), ...selectedColumnClass }),
+ headerCellClass: classNames({ "rdg-cell-editing": columnEditingName === attrId, ...selectedColumnClass })
+ };
+ }, [columnEditingName, gridContext, metadata]);
+
+ const measureColumnWidth = useCallback((attr: IAttribute) => {
+ const nameCellWidth = measureText(attr.name) + kHeaderCellPadding;
+ const xName = dataSet.attributes[0]?.name || "x";
+ const expr = metadata.rawExpressions.get(attr.id) ||
+ prettifyExpression(metadata.expressions.get(attr.id) || "", xName);
+ const exprCellWidth = (expr ? measureText(`= ${expr}`) : 0) + kExpressionCellPadding;
+ if ((nameCellWidth !== nameColumnWidths.current[attr.id]) ||
+ (exprCellWidth !== exprColumnWidths.current[attr.id])) {
+ // autoWidth changes (e.g. name or formula changes), supercede user-set width
+ delete userColumnWidths.current[attr.id];
+ nameColumnWidths.current[attr.id] = nameCellWidth;
+ exprColumnWidths.current[attr.id] = exprCellWidth;
+ }
+ return userColumnWidths.current[attr.id] || Math.max(nameCellWidth, exprCellWidth);
+ }, [dataSet.attributes, measureText, metadata.expressions, metadata.rawExpressions]);
+
+ const columns = useMemo(() => {
+ const cols: TColumn[] = attributes.map(attr => ({
+ ...cellClasses(attr.id),
+ name: attr.name,
+ key: attr.id,
+ width: measureColumnWidth(attr),
+ resizable: true,
+ headerRenderer: ColumnHeaderCell,
+ formatter: CellFormatter,
+ editor: !readOnly && !metadata.hasExpression(attr.id) ? CellTextEditor : undefined,
+ editorOptions: {
+ editOnClick: !readOnly
+ }
+ }));
+ cols.unshift({
+ cellClass: "index-column",
+ headerCellClass: "index-column-header",
+ name: "Index",
+ key: kIndexColumnKey,
+ width: kIndexColumnWidth,
+ maxWidth: kIndexColumnWidth,
+ resizable: false,
+ editable: false,
+ frozen: true,
+ headerRenderer: RowLabelHeader,
+ formatter: RowLabelFormatter
+ });
+ if (!readOnly) {
+ cols.push({
+ cellClass: "controls-column",
+ headerCellClass: "controls-column-header",
+ name: "Controls",
+ key: kControlsColumnKey,
+ width: kControlsColumnWidth,
+ maxWidth: kControlsColumnWidth,
+ resizable: false,
+ editable: false,
+ frozen: false,
+ headerRenderer: ControlsHeaderRenderer,
+ formatter: ControlsRowFormatter
+ });
+ }
+ columnChanges; // eslint-disable-line no-unused-expressions
+ return cols;
+ }, [attributes, RowLabelHeader, RowLabelFormatter, readOnly, columnChanges,
+ cellClasses, measureColumnWidth, metadata, ControlsHeaderRenderer, ControlsRowFormatter]);
+
+ useColumnExtensions({
+ gridContext, metadata, readOnly, columns, columnEditingName, changeHandlers,
+ setColumnEditingName: handleSetColumnEditingName, onShowExpressionsDialog
+ });
+
+ const onColumnResize = useCallback((idx: number, width: number) => {
+ userColumnWidths.current[columns[idx].key] = width;
+ }, [columns]);
+
+ return { columns, onColumnResize };
+};
diff --git a/src/components/tools/table-tool/use-content-change-handlers.ts b/src/components/tools/table-tool/use-content-change-handlers.ts
new file mode 100644
index 0000000000..7d93dca6e7
--- /dev/null
+++ b/src/components/tools/table-tool/use-content-change-handlers.ts
@@ -0,0 +1,175 @@
+import { useCallback } from "react";
+import { useCurrent } from "../../../hooks/use-current";
+import { ICase, ICaseCreation, IDataSet } from "../../../models/data/data-set";
+import { getGeometryContent } from "../../../models/tools/geometry/geometry-content";
+import {
+ getTableContentHeight, ILinkProperties, ITableChange, TableContentModelType
+} from "../../../models/tools/table/table-content";
+import { isLinkableValue, ITileLinkMetadata } from "../../../models/tools/table/table-model-types";
+import { ToolTileModelType } from "../../../models/tools/tool-tile";
+import { safeJsonParse, uniqueId, uniqueName } from "../../../utilities/js-utils";
+import { TColumn } from "./table-types";
+
+export interface IContentChangeHandlers {
+ onSetTableTitle: (title: string) => void;
+ onSetColumnName: (column: TColumn, columnName: string) => void;
+ onSetColumnExpressions: (rawExpressions: Map, xName: string) => void;
+ onAddColumn: () => void;
+ onRemoveColumn: (colId: string) => void;
+ onAddRows: (newCases: ICaseCreation[]) => void;
+ onUpdateRow: (caseValues: ICase) => void;
+ onRemoveRows: (rowIds: string[]) => void;
+ onLinkGeometryTile: (geomTileInfo: ITileLinkMetadata) => void;
+ onUnlinkGeometryTiles: () => void;
+}
+
+interface IProps {
+ model: ToolTileModelType;
+ dataSet: IDataSet;
+ readOnly?: boolean;
+ onRequestRowHeight: (options: { height?: number, deltaHeight?: number }) => void;
+ triggerColumnChange: () => void;
+ triggerRowChange: () => void;
+}
+export const useContentChangeHandlers = ({
+ model, dataSet, readOnly, onRequestRowHeight, triggerColumnChange
+}: IProps): IContentChangeHandlers => {
+ const modelRef = useCurrent(model);
+ const getContent = useCallback(() => modelRef.current.content as TableContentModelType, [modelRef]);
+
+ /*
+ * helper functions
+ */
+ // link information attached to individual table changes/actions
+ const getTableActionLinks = useCallback((newClientId?: string): ILinkProperties | undefined => {
+ const linkedClients = getContent().metadata.linkedGeometries;
+ // if there are no linked clients, then we don't need to attach link info to the action
+ if (!linkedClients?.length && !newClientId) return;
+ // id is used to link actions across tiles
+ const actionId = uniqueId();
+ const newClientIds = newClientId ? [newClientId] : [];
+ return { id: actionId, tileIds: [...linkedClients, ...newClientIds] };
+ }, [getContent]);
+
+ const syncChangeToLinkedClient = useCallback((clientTileId: string, linkId: string) => {
+ const tableContent = getContent();
+ const lastChange: ITableChange | undefined = safeJsonParse(tableContent.changes[tableContent.changes.length - 1]);
+ // eventually we'll presumably need to support other clients
+ const clientContent = getGeometryContent(getContent(), clientTileId);
+ // link information attached to individual client changes/actions
+ const clientActionLinks = getContent().getClientLinks(linkId, dataSet);
+ // synchronize the table change to the linked client
+ lastChange && clientContent?.syncLinkedChange(dataSet, lastChange, clientActionLinks);
+ }, [dataSet, getContent]);
+
+ const syncLinkedClients = useCallback((tableActionLinks?: ILinkProperties) => {
+ tableActionLinks?.tileIds.forEach(tileId => {
+ syncChangeToLinkedClient(tileId, tableActionLinks.id);
+ });
+ }, [syncChangeToLinkedClient]);
+
+ const validateCase = useCallback((aCase: ICaseCreation) => {
+ const newCase: ICaseCreation = { __id__: uniqueId() };
+ if (getContent().isLinked) {
+ // validate linkable values
+ dataSet.attributes.forEach(attr => {
+ const value = aCase[attr.id];
+ newCase[attr.id] = isLinkableValue(value) ? value : 0;
+ });
+ return newCase;
+ }
+ return { ...newCase, ...aCase };
+ }, [dataSet.attributes, getContent]);
+
+ const requestRowHeight = useCallback(() => {
+ const height = getTableContentHeight({
+ dataRows: dataSet.cases.length, readOnly, hasExpressions: getContent().hasExpressions
+ });
+ onRequestRowHeight({ height });
+ }, [dataSet.cases.length, getContent, onRequestRowHeight, readOnly]);
+
+ /*
+ * content change functions
+ */
+ const setTableTitle = useCallback((title: string) => {
+ if (readOnly) return;
+ (title != null) && getContent().setTableName(title);
+ triggerColumnChange();
+ }, [getContent, readOnly, triggerColumnChange]);
+
+ const setColumnName = useCallback((column: TColumn, columnName: string) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ getContent().setAttributeName(column.key, columnName, tableActionLinks);
+ syncLinkedClients(tableActionLinks);
+ }, [readOnly, getTableActionLinks, getContent, syncLinkedClients]);
+
+ const setColumnExpressions = useCallback((rawExpressions: Map, xName: string) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ getContent().setExpressions(rawExpressions, xName, tableActionLinks);
+ requestRowHeight();
+ syncLinkedClients(tableActionLinks);
+ }, [readOnly, getTableActionLinks, getContent, requestRowHeight, syncLinkedClients]);
+
+ const addColumn = useCallback(() => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ const attrId = uniqueId();
+ const attrName = uniqueName("y", (name: string) => !dataSet.attrFromName(name));
+ getContent().addAttribute(attrId, attrName, tableActionLinks);
+ syncLinkedClients(tableActionLinks);
+ }, [dataSet, getContent, getTableActionLinks, readOnly, syncLinkedClients]);
+
+ const removeColumn = useCallback((colId: string) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ getContent().removeAttributes([colId], tableActionLinks);
+ syncLinkedClients(tableActionLinks);
+ }, [getContent, getTableActionLinks, readOnly, syncLinkedClients]);
+
+ const addRows = useCallback((newCases: ICaseCreation[]) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ const cases = newCases.map(aCase => validateCase(aCase));
+ getContent().addCanonicalCases(cases, undefined, tableActionLinks);
+ requestRowHeight();
+ syncLinkedClients(tableActionLinks);
+ }, [readOnly, getTableActionLinks, getContent, requestRowHeight, syncLinkedClients, validateCase]);
+
+ const updateRow = useCallback((caseValues: ICase) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ getContent().setCanonicalCaseValues([caseValues], tableActionLinks);
+ syncLinkedClients(tableActionLinks);
+ }, [readOnly, getTableActionLinks, getContent, syncLinkedClients]);
+
+ const removeRows = useCallback((rowIds: string[]) => {
+ if (readOnly) return;
+ const tableActionLinks = getTableActionLinks();
+ getContent().removeCases(rowIds, tableActionLinks);
+ syncLinkedClients(tableActionLinks);
+ }, [readOnly, getTableActionLinks, getContent, syncLinkedClients]);
+
+ const linkGeometryTile = useCallback((geomTileInfo: ITileLinkMetadata) => {
+ if (!getContent().isValidForGeometryLink()) return;
+
+ // add action to table content
+ const tableActionLinks = getTableActionLinks(geomTileInfo.id);
+ if (!tableActionLinks) return;
+ getContent().addGeometryLink(geomTileInfo.id, tableActionLinks);
+ // sync change to the newly linked client - not all linked clients
+ syncChangeToLinkedClient(geomTileInfo.id, tableActionLinks.id);
+ }, [getContent, getTableActionLinks, syncChangeToLinkedClient]);
+
+ const unlinkGeometryTiles = useCallback(() => {
+ const geometryIds = getContent().metadata.linkedGeometries.toJS();
+ const tableActionLinks = getTableActionLinks();
+ getContent().removeGeometryLinks(geometryIds, tableActionLinks);
+ }, [getContent, getTableActionLinks]);
+
+ return { onSetTableTitle: setTableTitle, onSetColumnName: setColumnName, onSetColumnExpressions: setColumnExpressions,
+ onAddColumn: addColumn, onRemoveColumn: removeColumn,
+ onAddRows: addRows, onUpdateRow: updateRow, onRemoveRows: removeRows,
+ onLinkGeometryTile: linkGeometryTile, onUnlinkGeometryTiles: unlinkGeometryTiles };
+};
diff --git a/src/components/tools/table-tool/use-controls-column.tsx b/src/components/tools/table-tool/use-controls-column.tsx
new file mode 100644
index 0000000000..a844d54f81
--- /dev/null
+++ b/src/components/tools/table-tool/use-controls-column.tsx
@@ -0,0 +1,62 @@
+import React, { useCallback } from "react";
+import { Tooltip } from "react-tippy";
+import AddColumnSvg from "../../../assets/icons/add/add.nosvgo.svg";
+import RemoveRowSvg from "../../../assets/icons/remove/remove.nosvgo.svg";
+import { useTooltipOptions } from "../../../hooks/use-tooltip-options";
+import { TFormatterProps } from "./table-types";
+
+interface IUseControlsColumn {
+ readOnly?: boolean;
+ onAddColumn?: () => void;
+ onRemoveRow?: (rowId: string) => void;
+}
+export const useControlsColumn = ({ readOnly, onAddColumn, onRemoveRow }: IUseControlsColumn) => {
+
+ const kTooltipDistance = -35; // required to get tooltip to line up just below the cell
+ const addColumnTooltipOptions = useTooltipOptions({ title: "Add column", distance: kTooltipDistance });
+ const ControlsHeaderRenderer: React.FC = useCallback(() => {
+ return !readOnly
+ ?
+
+
+ : null;
+ }, [addColumnTooltipOptions, onAddColumn, readOnly]);
+
+ const removeRowTooltipOptions = useTooltipOptions({ title: "Remove row", distance: kTooltipDistance });
+ const ControlsRowFormatter: React.FC = useCallback(({ rowIdx, row, isRowSelected }) => {
+ const showRemoveButton = !readOnly && (isRowSelected || row.__context__.isSelectedCellInRow(rowIdx));
+ return showRemoveButton
+ ?
+
+
+ : null;
+ }, [onRemoveRow, readOnly, removeRowTooltipOptions]);
+ ControlsRowFormatter.displayName = "ControlsRowFormatter";
+
+ return { ControlsHeaderRenderer, ControlsRowFormatter };
+};
+
+interface IAddColumnButtonProps {
+ onAddColumn?: () => void;
+}
+const AddColumnButton: React.FC = ({ onAddColumn }) => {
+ return (
+
+ );
+};
+AddColumnButton.displayName = "AddColumnButton";
+
+interface IRemoveRowButtonProps {
+ rowId: string;
+ onRemoveRow?: (rowId: string) => void;
+}
+const RemoveRowButton: React.FC = ({ rowId, onRemoveRow }) => {
+ return (
+ onRemoveRow?.(rowId)}>
+
+
+ );
+};
+RemoveRowButton.displayName = "RemoveRowButton";
diff --git a/src/components/tools/table-tool/use-data-set.ts b/src/components/tools/table-tool/use-data-set.ts
new file mode 100644
index 0000000000..9346e7a9ac
--- /dev/null
+++ b/src/components/tools/table-tool/use-data-set.ts
@@ -0,0 +1,128 @@
+import { useCallback } from "react";
+import { DataGridHandle } from "react-data-grid";
+import { useCurrent } from "../../../hooks/use-current";
+import { ICase, IDataSet } from "../../../models/data/data-set";
+import { TableContentModelType } from "../../../models/tools/table/table-content";
+import { ToolTileModelType } from "../../../models/tools/tool-tile";
+import { uniqueId } from "../../../utilities/js-utils";
+import { formatValue } from "./cell-formatter";
+import { IGridContext, TColumn, TPosition, TRow } from "./table-types";
+import { useColumnsFromDataSet } from "./use-columns-from-data-set";
+import { IContentChangeHandlers } from "./use-content-change-handlers";
+import { useNumberFormat } from "./use-number-format";
+import { useRowsFromDataSet } from "./use-rows-from-data-set";
+
+const isCellSelectable = (position: TPosition, columns: TColumn[]) => {
+ return (position.idx !== 0) && (position.idx !== columns.length - 1);
+};
+
+interface IUseDataSet {
+ gridRef: React.RefObject;
+ gridContext: IGridContext;
+ model: ToolTileModelType;
+ dataSet: IDataSet;
+ columnChanges: number;
+ triggerColumnChange: () => void;
+ rowChanges: number;
+ readOnly: boolean;
+ inputRowId: React.MutableRefObject;
+ selectedCell: React.MutableRefObject;
+ RowLabelHeader: React.FC;
+ RowLabelFormatter: React.FC;
+ changeHandlers: IContentChangeHandlers;
+ measureText: (text: string) => number;
+ onShowExpressionsDialog?: (attrId?: string) => void;
+}
+export const useDataSet = ({
+ gridRef, gridContext, model, dataSet, columnChanges, triggerColumnChange, rowChanges, readOnly, inputRowId,
+ selectedCell, RowLabelHeader, RowLabelFormatter, changeHandlers, measureText, onShowExpressionsDialog
+}: IUseDataSet) => {
+ const { onAddRows, onUpdateRow } = changeHandlers;
+ const modelRef = useCurrent(model);
+ const getContent = useCallback(() => modelRef.current.content as TableContentModelType, [modelRef]);
+ const metadata = getContent().metadata;
+ const { columns, onColumnResize } = useColumnsFromDataSet({
+ gridContext, dataSet, metadata, readOnly, columnChanges, RowLabelHeader, RowLabelFormatter,
+ measureText, onShowExpressionsDialog, changeHandlers });
+ const onSelectedCellChange = (position: TPosition) => {
+ const forward = (selectedCell.current.rowIdx < position.rowIdx) ||
+ ((selectedCell.current.rowIdx === position.rowIdx) &&
+ (selectedCell.current.idx < position.idx));
+ selectedCell.current = position;
+
+ if (!isCellSelectable(position, columns) && (columns.length > 2)) {
+ let newPosition = { ...position };
+ if (forward) {
+ while (!isCellSelectable(newPosition, columns)) {
+ // move from last cell to { -1, -1 }
+ if ((newPosition.rowIdx >= rows.length) ||
+ ((newPosition.rowIdx === rows.length - 1) && (newPosition.idx >= columns.length - 1))) {
+ newPosition = { rowIdx: -1, idx: -1 };
+ }
+ // otherwise advance to next selectable cell
+ else if (++newPosition.idx >= columns.length) {
+ newPosition.idx = 1;
+ ++newPosition.rowIdx;
+ }
+ }
+ }
+ else { // backward
+ while (!isCellSelectable(newPosition, columns)) {
+ // move from first cell to { -1, -1 }
+ if ((newPosition.rowIdx <= -1) || ((newPosition.rowIdx === 0) && (newPosition.idx < 1))) {
+ newPosition = { rowIdx: -1, idx: -1 };
+ }
+ // otherwise move to previous selectable cell
+ else if (--newPosition.idx < 1) {
+ newPosition.idx = columns.length - 2;
+ --newPosition.rowIdx;
+ }
+ }
+ }
+ if ((newPosition.rowIdx !== position.rowIdx) || (newPosition.idx !== position.idx)) {
+ gridRef.current?.selectCell(newPosition);
+ }
+ }
+ };
+
+ const hasLinkableRows = getContent().hasLinkableCases(dataSet);
+
+ const { rows, rowKeyGetter, rowClass } = useRowsFromDataSet({
+ dataSet, readOnly, inputRowId: inputRowId.current,
+ rowChanges, context: gridContext});
+ const formatter = useNumberFormat();
+ const onRowsChange = (_rows: TRow[]) => {
+ // for now, assume that all changes are single cell edits
+ const selectedCellRowIndex = selectedCell.current?.rowIdx;
+ const selectedCellColIndex = selectedCell.current?.idx;
+ const updatedRow = (selectedCellRowIndex != null) && (selectedCellRowIndex >= 0)
+ ? _rows[selectedCellRowIndex] : undefined;
+ const updatedColumn = (selectedCellColIndex != null) && (selectedCellColIndex >= 0)
+ ? columns[selectedCellColIndex] : undefined;
+ if (!readOnly && updatedRow && updatedColumn) {
+ const originalValue = dataSet.getValue(updatedRow.__id__, updatedColumn.key);
+ const originalStrValue = formatValue(formatter, originalValue);
+ // only make a change if the value has actually changed
+ if (updatedRow[updatedColumn.key] !== originalStrValue) {
+ const updatedCaseValues: ICase = {
+ __id__: updatedRow.__id__,
+ [updatedColumn.key]: updatedRow[updatedColumn.key]
+ };
+ const inputRowIndex = _rows.findIndex(row => row.__id__ === inputRowId.current);
+ if ((inputRowIndex >= 0) && (selectedCellRowIndex === inputRowIndex)) {
+ onAddRows([updatedCaseValues]);
+ inputRowId.current = uniqueId();
+ }
+ else {
+ onUpdateRow(updatedCaseValues);
+ }
+ }
+ }
+ };
+ const handleColumnResize = useCallback((idx: number, width: number) => {
+ onColumnResize(idx, width);
+ triggerColumnChange();
+ }, [onColumnResize, triggerColumnChange]);
+ return { hasLinkableRows, columns, rows, rowKeyGetter, rowClass,
+ onColumnResize: handleColumnResize, onRowsChange, onSelectedCellChange};
+};
diff --git a/src/components/tools/table-tool/use-editable-expressions.ts b/src/components/tools/table-tool/use-editable-expressions.ts
new file mode 100644
index 0000000000..7cd738b0cd
--- /dev/null
+++ b/src/components/tools/table-tool/use-editable-expressions.ts
@@ -0,0 +1,18 @@
+import { useLayoutEffect, useRef } from "react";
+import { useCurrent } from "../../../hooks/use-current";
+import { TableMetadataModelType } from "../../../models/tools/table/table-content";
+import { getEditableExpression } from "./expression-utils";
+
+export const useEditableExpressions = (metadata: TableMetadataModelType, xName: string) => {
+ const metadataRef = useCurrent(metadata);
+ const editExpressions = useRef