From acd2f84d82e270674c4313b3c010bcb242458837 Mon Sep 17 00:00:00 2001 From: Ethan McElroy Date: Mon, 16 Sep 2024 16:50:31 -0400 Subject: [PATCH] feat: add attribute in nested table --- src/components/add-attribute-button.scss | 33 +++++++++ src/components/add-attribute-button.tsx | 28 ++++++++ src/components/app.tsx | 13 ++-- src/components/draggable-table-tags.tsx | 74 ++++++++++++-------- src/components/editable-table-cell.scss | 18 +++++ src/components/editable-table-cell.tsx | 17 +++-- src/components/editable-table-header.tsx | 64 +++++++++++++++++ src/components/flat-table.tsx | 4 +- src/components/landscape-view.tsx | 29 ++++++-- src/components/menu.tsx | 31 ++++++--- src/components/nested-table.tsx | 54 +++++--------- src/components/portrait-view.tsx | 89 ++++++++++++++---------- src/components/table-cell.tsx | 74 ++++++++++++++++++++ src/components/table-headers.tsx | 52 ++++++++++++++ src/components/tables.scss | 33 ++++++++- src/hooks/useCodapState.tsx | 64 +++++++++++++++-- src/types.ts | 60 +++++++++++++--- 17 files changed, 590 insertions(+), 147 deletions(-) create mode 100644 src/components/add-attribute-button.scss create mode 100644 src/components/add-attribute-button.tsx create mode 100644 src/components/editable-table-header.tsx create mode 100644 src/components/table-cell.tsx create mode 100644 src/components/table-headers.tsx diff --git a/src/components/add-attribute-button.scss b/src/components/add-attribute-button.scss new file mode 100644 index 0000000..c5f5aeb --- /dev/null +++ b/src/components/add-attribute-button.scss @@ -0,0 +1,33 @@ +.addAttributeButtonContainer { + display: inline-block; + margin-left: 10px; + + .addAttributeButton { + all: unset; + align-items: center; + border-radius: 50%; + color: #333; + cursor: pointer; + display: flex; + font-size: 11px; + font-weight: bold; + height: 12px; + opacity: 0.5; + + &:hover { + opacity: 1; + } + + svg { + background: #333; + border-radius: 50%; + height: 12px; + margin-right: 5px; + width: 12px; + + path { + fill: white; + } + } + } +} diff --git a/src/components/add-attribute-button.tsx b/src/components/add-attribute-button.tsx new file mode 100644 index 0000000..186b917 --- /dev/null +++ b/src/components/add-attribute-button.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { ICollection, ICollections } from "../types"; + +import AddIcon from "../assets/add-icon.svg"; + +import css from "./add-attribute-button.scss"; + +interface IProps { + collectionId: number; + collections: ICollections; + handleAddAttribute?: (collection: ICollection, attrName: string) => Promise; +} + +export const AddAttributeButton: React.FC = ({collections, collectionId, handleAddAttribute}: IProps) => { + + const handleAddAttributeToCollection = async () => { + const collection = collections.find((c) => c.id === collectionId); + collection && handleAddAttribute && await handleAddAttribute(collection, ""); + }; + + return ( +
+ +
+ ); +}; diff --git a/src/components/app.tsx b/src/components/app.tsx index d2d4db7..aac3ebe 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -13,7 +13,7 @@ function App() { handleSelectDataSet: _handleSelectDataSet, handleUpdateAttributePosition, handleAddCollection, handleAddAttribute, handleSetCollections, handleSelectSelf, updateTitle, selectCODAPCases, listenForSelectionChanges, - handleCreateCollectionFromAttribute, handleUpdateCollections + handleCreateCollectionFromAttribute, renameAttribute } = useCodapState(); useEffect(() => { @@ -32,10 +32,14 @@ function App() { updateInteractiveState({view}); }, [updateInteractiveState]); - const handleSelectDataSet = useCallback((e: React.ChangeEvent) => { + const handleSelectDataSet = useCallback((e: React.ChangeEvent, defaultDisplayMode?: string) => { const dataSetName = e.target.value; _handleSelectDataSet(dataSetName); - updateInteractiveState({dataSetName}); + const update: Partial = {dataSetName}; + if (defaultDisplayMode) { + update.displayMode = defaultDisplayMode; + } + updateInteractiveState(update); }, [_handleSelectDataSet, updateInteractiveState]); const handleShowComponent = () => { @@ -104,7 +108,8 @@ function App() { handleUpdateAttributePosition={handleUpdateAttributePosition} handleSetCollections={handleSetCollections} handleCreateCollectionFromAttribute={handleCreateCollectionFromAttribute} - handleUpdateCollections={handleUpdateCollections} + handleAddAttribute={handleAddAttribute} + renameAttribute={renameAttribute} /> ); diff --git a/src/components/draggable-table-tags.tsx b/src/components/draggable-table-tags.tsx index 947bcdb..c2c3a9c 100644 --- a/src/components/draggable-table-tags.tsx +++ b/src/components/draggable-table-tags.tsx @@ -5,8 +5,10 @@ import { useTableTopScrollTopContext } from "../hooks/useTableScrollTop"; import { useCodapState } from "../hooks/useCodapState"; import { getAttribute } from "@concord-consortium/codap-plugin-api"; import { getCollectionById } from "../utils/apiHelpers"; -import { PropsWithChildren } from "../types"; +import { ICollection, ICollections, PropsWithChildren } from "../types"; import { EditableTableCell } from "./editable-table-cell"; +import { AddAttributeButton } from "./add-attribute-button"; +import { EditableTableHeader } from "./editable-table-header"; import AddIcon from "../assets/plus-level-1.svg"; import DropdownIcon from "../assets/dropdown-arrow-icon.svg"; @@ -38,10 +40,14 @@ interface DraggagleTableHeaderProps { colSpan?: number; dataSetName: string; dataSetTitle: string; + editableHasFocus?: boolean; + isParent?: boolean; + attrId?: number; + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise; } export const DraggagleTableHeader: React.FC> = (props) => { - const {collectionId, attrTitle, dataSetName, children} = props; + const {collectionId, attrTitle, dataSetName, editableHasFocus, children, isParent, attrId, renameAttribute} = props; const {dragOverId, dragSide, handleDragStart, handleDragOver, handleOnDrop, handleDragEnter, handleDragLeave, handleDragEnd} = useDraggableTableContext(); const {handleSortAttribute} = useCodapState(); @@ -69,8 +75,7 @@ export const DraggagleTableHeader: React.FC) => { + const handleShowHeaderMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowHeaderMenu(!showHeaderMenu); @@ -83,6 +88,7 @@ export const DraggagleTableHeader: React.FC setShowDropdownIcon(true)} - onMouseLeave={() => setShowDropdownIcon(false)} - onClick={handleShowHeaderMenu} > -
-
{children}
- {showDropdownIcon && -
- -
+
+ + { + }
@@ -130,14 +144,16 @@ export const DraggagleTableHeader: React.FC Promise; } export const DroppableTableHeader: React.FC> = (props) => { - const {collectionId, children} = props; - const {dragOverId, handleDragOver, handleOnDrop, handleDragEnter, - handleDragLeave} = useDraggableTableContext(); - + const {childCollectionId, collectionId, collections, children, handleAddAttribute} = props; + const {dragOverId, handleDragOver, handleOnDrop, handleDragEnter, handleDragLeave} = useDraggableTableContext(); const id = `${collectionId}`; const style = getStyle(id, dragOverId, "left"); @@ -150,7 +166,14 @@ export const DroppableTableHeader: React.FC - {children} +
+ {children} + +
); }; @@ -161,15 +184,12 @@ interface DraggagleTableDataProps { caseId: string; style?: React.CSSProperties; isParent?: boolean; - resizeCounter?: number; parentLevel?: number; selectedDataSetName: string; - handleUpdateCollections: () => void; } export const DraggagleTableData: React.FC> = (props) => { - const {collectionId, attrTitle, children, caseId, isParent, resizeCounter, parentLevel=0, - selectedDataSetName, handleUpdateCollections} = props; + const {collectionId, attrTitle, children, caseId, isParent, parentLevel=0, selectedDataSetName} = props; const {dragOverId, dragSide} = useDraggableTableContext(); const {style} = getIdAndStyle(collectionId, attrTitle, dragOverId, dragSide); const {tableScrollTop, scrollY} = useTableTopScrollTopContext(); @@ -205,15 +225,13 @@ export const DraggagleTableData: React.FC { return ( diff --git a/src/components/editable-table-cell.scss b/src/components/editable-table-cell.scss index 79557f2..884fbdc 100644 --- a/src/components/editable-table-cell.scss +++ b/src/components/editable-table-cell.scss @@ -1,5 +1,23 @@ .editableTableCell { + height: 100%; + min-height: 16px; + width: 100%; + + div:first-child { + height: 100%; + min-height: 16px; + width: 100%; + + span:empty { + display: block; + height: 100%; + min-height: 16px; + width: 100%; + } + } + input { + background: white; width: calc(100% - 8px); } } \ No newline at end of file diff --git a/src/components/editable-table-cell.tsx b/src/components/editable-table-cell.tsx index 1141605..a8879bc 100644 --- a/src/components/editable-table-cell.tsx +++ b/src/components/editable-table-cell.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, useState } from "react"; import { Editable, EditablePreview, EditableInput } from "@chakra-ui/react"; -import { updateCaseById } from "@concord-consortium/codap-plugin-api"; +import { useCodapState } from "../hooks/useCodapState"; import css from "./editable-table-cell.scss"; @@ -8,13 +8,13 @@ interface IProps { attrTitle: string; caseId: string; children: ReactNode; - handleUpdateCollections: () => void; selectedDataSetName: string; } export const EditableTableCell = (props: IProps) => { - const { attrTitle, caseId, children, handleUpdateCollections, selectedDataSetName } = props; - const displayValue = String(children); + const { attrTitle, caseId, children } = props; + const { editCaseValue } = useCodapState(); + const [displayValue, setDisplayValue] = useState(String(children)); const [editingValue, setEditingValue] = useState(displayValue); const [isEditing, setIsEditing] = useState(false); @@ -28,11 +28,16 @@ export const EditableTableCell = (props: IProps) => { }; const handleSubmit = async (newValue: string) => { + if (newValue === displayValue) { + setIsEditing(false); + return; + } + try { - await updateCaseById(selectedDataSetName, caseId, {[attrTitle]: newValue}); + await editCaseValue(newValue, caseId, attrTitle); + setDisplayValue(newValue); setEditingValue(newValue); setIsEditing(false); - handleUpdateCollections(); } catch (e) { console.error("Case not updated: ", e); } diff --git a/src/components/editable-table-header.tsx b/src/components/editable-table-header.tsx new file mode 100644 index 0000000..ed49a05 --- /dev/null +++ b/src/components/editable-table-header.tsx @@ -0,0 +1,64 @@ +import React, { ReactNode, useState } from "react"; +import { Editable, EditablePreview, EditableInput } from "@chakra-ui/react"; + +import css from "./editable-table-cell.scss"; + +interface IProps { + attrId: number; + collectionName: string; + collectionId: number; + children?: ReactNode; + hasFocus?: boolean; + selectedDataSetName?: string; + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise; +} + +export const EditableTableHeader = (props: IProps) => { + const { attrId, collectionName, hasFocus, renameAttribute } = props; + const [displayValue, setDisplayValue] = useState(collectionName); + const [editingValue, setEditingValue] = useState(displayValue); + const [isEditing, setIsEditing] = useState(hasFocus); + + const handleChangeValue = (newValue: string) => { + setEditingValue(newValue); + }; + + const handleCancel = () => { + setEditingValue(displayValue); + setIsEditing(false); + }; + + const handleSubmit = async (newValue: string) => { + if (newValue === displayValue) { + setIsEditing(false); + return; + } + + try { + await renameAttribute(collectionName, attrId, displayValue, newValue); + setDisplayValue(newValue); + setEditingValue(newValue); + setIsEditing(false); + } catch (e) { + console.error("Case not updated: ", e); + } + }; + + return ( +
+ setIsEditing(true)} + onSubmit={handleSubmit} + startWithEditView={hasFocus} + submitOnBlur={true} + value={isEditing ? editingValue : displayValue} + > + {!isEditing && } + + +
+ ); +}; diff --git a/src/components/flat-table.tsx b/src/components/flat-table.tsx index ed9d636..5373b97 100644 --- a/src/components/flat-table.tsx +++ b/src/components/flat-table.tsx @@ -10,7 +10,8 @@ interface IFlatProps extends ITableProps { } export const FlatTable = (props: IFlatProps) => { - const {selectedDataSet, collections, collectionClasses, cases, mapCellsFromValues, showHeaders } = props; + const {selectedDataSet, collections, collectionClasses, cases, mapCellsFromValues, + showHeaders, renameAttribute } = props; const collection = collections[0]; const {className} = collectionClasses[0]; const attrVisibilities = getAttrVisibility(collections); @@ -46,6 +47,7 @@ export const FlatTable = (props: IFlatProps) => { attrTitle={attr.title} dataSetName={selectedDataSet.name} dataSetTitle={selectedDataSet.title} + renameAttribute={renameAttribute} > {attr.title} )} diff --git a/src/components/landscape-view.tsx b/src/components/landscape-view.tsx index 3516b9d..7ea218b 100644 --- a/src/components/landscape-view.tsx +++ b/src/components/landscape-view.tsx @@ -2,12 +2,13 @@ import React from "react"; import { ICollection, IProcessedCaseObj, ITableProps } from "../types"; import { DraggagleTableHeader } from "./draggable-table-tags"; import { getAttrPrecisions, getAttrTypes, getAttrVisibility } from "../utils/utils"; +import { TableHeaders } from "./table-headers"; import css from "./tables.scss"; export const LandscapeView = (props: ITableProps) => { - const {mapCellsFromValues, mapHeadersFromValues, showHeaders, collectionClasses, - getClassName, selectedDataSet, collections, getValueLength, paddingStyle} = props; + const {mapCellsFromValues, showHeaders, collectionClasses, + getClassName, selectedDataSet, collections, getValueLength, paddingStyle, renameAttribute} = props; const renderNestedTable = (parentColl: ICollection) => { const headers = parentColl.cases.map((caseObj) => caseObj.values); @@ -26,7 +27,19 @@ export const LandscapeView = (props: ITableProps) => { {parentColl.name} } - {headers.map(values => mapHeadersFromValues(parentColl.id, "first-row", values, attrVisibilities))} + {headers.map(values => { + return ( + + ); + })} {firstRowValues.map(values => @@ -73,7 +86,14 @@ export const LandscapeView = (props: ITableProps) => { } {isFirstIndex && - {mapHeadersFromValues(collection.id, `first-row-${index}`, values, attrVisibilities)} + } @@ -120,6 +140,7 @@ export const LandscapeView = (props: ITableProps) => { colSpan={getValueLength(firstRowValues)} dataSetName={selectedDataSet.name} dataSetTitle={selectedDataSet.title} + renameAttribute={renameAttribute} > {selectedDataSet.name} diff --git a/src/components/menu.tsx b/src/components/menu.tsx index 58bce98..b60e073 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -1,20 +1,22 @@ import React from "react"; import { IDataSet } from "../types"; + import css from "./menu.scss"; interface IProps { selectedDataSet: any, - handleSelectDataSet: (e: React.ChangeEvent) => void, + handleSelectDataSet: (e: React.ChangeEvent, defaultDisplayMode?: string) => void, dataSets: Array, // these are optional and only used by the nested table view - handleSelectDisplayMode?: (e: React.ChangeEvent) => void, + handleSelectDisplayMode?: (mode: string) => void, togglePadding?: () => void, toggleShowHeaders?: () => void, showHeaders?: boolean, padding?: boolean; displayMode?: string; showDisplayMode?: boolean; + defaultDisplayMode?: string; } const portrait = "Portrait"; @@ -22,18 +24,29 @@ const landscape = "Landscape"; const none = ""; export const Menu = (props: IProps) => { - const {handleSelectDataSet, dataSets, handleSelectDisplayMode, togglePadding, - showHeaders, padding, toggleShowHeaders, displayMode, selectedDataSet, showDisplayMode} = props; + const { handleSelectDataSet, dataSets, handleSelectDisplayMode, togglePadding, showHeaders, + padding, toggleShowHeaders, displayMode, selectedDataSet, showDisplayMode, defaultDisplayMode} = props; + + console.log("Menu prop displayMode: ", displayMode); + + const handleDataSetSelection = (e: React.ChangeEvent) => { + if (handleSelectDisplayMode && defaultDisplayMode) { + console.log("update display mode: ", defaultDisplayMode); + handleSelectDisplayMode(defaultDisplayMode); + } + handleSelectDataSet(e, defaultDisplayMode); + }; const displayModes = [none, portrait, landscape]; return (
Select a Dataset: - + {dataSets?.length && dataSets.map((set) => { - return (); + const dataSetIdentifier = set.title || set.name; + return (); })}
@@ -41,10 +54,10 @@ export const Menu = (props: IProps) => { {showDisplayMode && handleSelectDisplayMode &&
Display mode: - handleSelectDisplayMode(e.currentTarget.value)} defaultValue={displayMode}> {displayModes.map((mode) => { return ( - ); diff --git a/src/components/nested-table.tsx b/src/components/nested-table.tsx index 5858d1c..0aa61f0 100644 --- a/src/components/nested-table.tsx +++ b/src/components/nested-table.tsx @@ -7,7 +7,7 @@ import { Menu } from "./menu"; import { LandscapeView } from "./landscape-view"; import { FlatTable } from "./flat-table"; import { DraggableTableContext, useDraggableTable } from "../hooks/useDraggableTable"; -import { DraggagleTableData, DraggagleTableHeader } from "./draggable-table-tags"; +import { DraggagleTableData } from "./draggable-table-tags"; import css from "./nested-table.scss"; @@ -20,21 +20,22 @@ interface IProps { dataSets: IDataSet[]; collections: ICollections; cases: CaseValuesWithId[]; - interactiveState: InteractiveState - handleSelectDataSet: (e: React.ChangeEvent) => void - updateInteractiveState: (update: Partial) => void - handleShowComponent: () => void - handleSetCollections: (collections: Array) => void - handleUpdateAttributePosition: (collection: ICollection, attrName: string, newPosition: number) => void - handleCreateCollectionFromAttribute: (collection: ICollection, attr: any, parent: number|string) => Promise - handleUpdateCollections: () => void + interactiveState: InteractiveState; + handleSelectDataSet: (e: React.ChangeEvent, defaultDisplayMode?: string) => void; + updateInteractiveState: (update: Partial) => void; + handleShowComponent: () => void; + handleSetCollections: (collections: Array) => void; + handleUpdateAttributePosition: (collection: ICollection, attrName: string, newPosition: number) => void; + handleCreateCollectionFromAttribute: (collection: ICollection, attr: any, parent: number|string) => Promise; + handleAddAttribute?: (collection: ICollection, attrName: string) => Promise; + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise; } export const NestedTable = (props: IProps) => { const {selectedDataSet, dataSets, collections, cases, interactiveState, handleSelectDataSet, updateInteractiveState, handleShowComponent, handleSetCollections, handleUpdateAttributePosition, - handleCreateCollectionFromAttribute, handleUpdateCollections} = props; + handleCreateCollectionFromAttribute, renameAttribute, handleAddAttribute} = props; const [collectionClasses, setCollectionClasses] = useState>([]); const [paddingStyle, setPaddingStyle] = useState>({padding: "0px"}); @@ -87,35 +88,14 @@ export const NestedTable = (props: IProps) => { updateInteractiveState({showHeaders: !interactiveState.showHeaders}); }, [interactiveState, updateInteractiveState]); - const handleSelectDisplayMode = useCallback((e: React.ChangeEvent) => { - updateInteractiveState({displayMode: e.target.value}); + const handleSelectDisplayMode = useCallback((mode: string) => { + updateInteractiveState({displayMode: mode}); }, [updateInteractiveState]); - const mapHeadersFromValues = (collectionId: number, rowKey: string, values: Values, - attrVisibilities: Record) => { - return ( - <> - {(Object.keys(values)).map((key, index) => { - if (!attrVisibilities[key] && (typeof values[key] === "string" || typeof values[key] === "number")) { - return ( - {key} - - ); - } - })} - - ); - }; const mapCellsFromValues = (collectionId: number, rowKey: string, aCase: Values, precisions: Record, attrTypes: Record, - attrVisibilities: Record, isParent?: boolean, resizeCounter?: number, parentLevel?: number) => { + attrVisibilities: Record, isParent?: boolean, parentLevel?: number) => { return Object.keys(aCase).map((key, index) => { if (key === "id") return null; @@ -153,10 +133,8 @@ export const NestedTable = (props: IProps) => { key={`${rowKey}-${val}-${index}}`} isParent={isParent} caseId={aCase.id} - resizeCounter={resizeCounter} parentLevel={parentLevel} selectedDataSetName={selectedDataSet.name} - handleUpdateCollections={handleUpdateCollections} > {val} @@ -178,7 +156,8 @@ export const NestedTable = (props: IProps) => { const isNoHierarchy = collections.length === 1; const classesExist = collectionClasses.length > 0; const tableProps = {showHeaders: interactiveState.showHeaders, collectionClasses, collections, selectedDataSet, - getClassName, mapHeadersFromValues, mapCellsFromValues, getValueLength, paddingStyle, handleUpdateCollections}; + getClassName, mapCellsFromValues, getValueLength, paddingStyle, + dataSetName: selectedDataSet.name, renameAttribute, handleAddAttribute}; const flatProps = {...tableProps, cases}; if (isNoHierarchy && classesExist) { return ; @@ -208,6 +187,7 @@ export const NestedTable = (props: IProps) => { padding={interactiveState.padding} displayMode={interactiveState.displayMode} showDisplayMode={showDisplayMode} + defaultDisplayMode="Portrait" /> {selectedDataSet && renderTable()} diff --git a/src/components/portrait-view.tsx b/src/components/portrait-view.tsx index b13a8c0..e90ff33 100644 --- a/src/components/portrait-view.tsx +++ b/src/components/portrait-view.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useRef } from "react"; import { ICollection, IProcessedCaseObj, ITableProps } from "../types"; import { DraggableTableContainer, DroppableTableData, DroppableTableHeader } from "./draggable-table-tags"; import { TableScrollTopContext, useTableScrollTop } from "../hooks/useTableScrollTop"; import { getAttrPrecisions, getAttrTypes, getAttrVisibility } from "../utils/utils"; +import { AddAttributeButton } from "./add-attribute-button"; +import { TableHeaders } from "./table-headers"; import css from "./tables.scss"; @@ -10,12 +12,13 @@ export type PortraitViewRowProps = {collectionId: number, caseObj: IProcessedCas precisions: Record, attrTypes: Record, attrVisibilities: Record, - isParent: boolean, resizeCounter: number, parentLevel?: number} + isParent: boolean, parentLevel?: number, dataSetName: string} & ITableProps; export const PortraitViewRow = (props: PortraitViewRowProps) => { - const {paddingStyle, mapCellsFromValues, mapHeadersFromValues, showHeaders, precisions, attrTypes, attrVisibilities, - getClassName, collectionId, caseObj, index, isParent, resizeCounter, parentLevel} = props; + const {paddingStyle, mapCellsFromValues, showHeaders, precisions, attrTypes, attrVisibilities, + getClassName, collectionId, caseObj, index, isParent, parentLevel, dataSetName, + handleAddAttribute, collections, renameAttribute} = props; const {children, id, values} = caseObj; const caseValuesWithId = {...values, id}; @@ -30,16 +33,34 @@ export const PortraitViewRow = (props: PortraitViewRowProps) => { <> {index === 0 && - {mapHeadersFromValues(collectionId, `first-row-${index}`, values, attrVisibilities)} + {showHeaders ? ( - {children[0].collection.name} + + {children[0].collection.name} + ) : } } {mapCellsFromValues( collectionId, `parent-row-${index}`, caseValuesWithId, precisions, attrTypes, attrVisibilities, - isParent, resizeCounter, parentLevel + isParent, parentLevel )} @@ -58,8 +79,17 @@ export const PortraitViewRow = (props: PortraitViewRowProps) => { return ( - {mapHeadersFromValues(child.collection.id, `child-row-${index}-${i}`, child.values, - attrVisibilities)} + @@ -79,35 +109,9 @@ export const PortraitViewRow = (props: PortraitViewRowProps) => { }; export const PortraitView = (props: ITableProps) => { - const {collectionClasses, selectedDataSet, collections, getValueLength} = props; + const {collectionClasses, selectedDataSet, collections, getValueLength, dataSetName, handleAddAttribute} = props; const tableRef = useRef(null); const tableScrollTop = useTableScrollTop(tableRef); - const [resizeCounter, setResizeCounter] = useState(0); - - const thresh = useMemo(() => { - const t: number[] = []; - for (let i = 0; i <= 100; i++) { - t.push(i/100); - } - return t; - }, []); - - - useEffect(() => { - const handleIntersection = (entries: IntersectionObserverEntry[], o: any) => { - setResizeCounter((prevState) => prevState + 1); - }; - const observer = new IntersectionObserver(handleIntersection, {threshold: thresh}); - document.querySelectorAll(`.parent-row`).forEach((row) => { - observer.observe(row); - }); - return () => { - document.querySelectorAll(`.parent-row`).forEach((row) => { - observer.unobserve(row); - }); - }; - - }, [thresh]); const renderTable = () => { const parentColl = collections.filter((coll: ICollection) => !coll.parent)[0]; @@ -126,7 +130,16 @@ export const PortraitView = (props: ITableProps) => { {selectedDataSet.name} - {parentColl.name} + +
+ {parentColl.name} + +
+ {parentColl.cases.map((caseObj, index) => ( { attrTypes={attrTypes} attrVisibilities={attrVisibilities} isParent={true} - resizeCounter={resizeCounter} parentLevel={0} + dataSetName={dataSetName} /> ))} diff --git a/src/components/table-cell.tsx b/src/components/table-cell.tsx new file mode 100644 index 0000000..e4018ba --- /dev/null +++ b/src/components/table-cell.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { DraggagleTableData } from "./draggable-table-tags"; + +interface ITableCellProps { + key: string; + index: number; + collectionId: number; + rowKey: string; + caseId: string; + attributeName: string; + cellValue: any; + precision: number; + attrType?: string|null; + isHidden: boolean; + isParent?: boolean; + selectedDataSet: string; + parentLevel?: number; +} + +export const TableCell: React.FC = (props) => { + const { + collectionId, + index, + rowKey, + caseId, + attributeName, + cellValue, + precision, + attrType, + isHidden, + isParent, + selectedDataSet, + parentLevel, + } = props; + + if (attributeName === "id" || (typeof cellValue !== "string" && typeof cellValue !== "number") || isHidden ) { + return null; + } + + let displayValue: string|number; + const isNumericType= attrType === "numeric"; + const hasValue= cellValue !== ""; + const parsedValue: number = typeof cellValue === "string" ? parseFloat(cellValue) : NaN; + const isNumber= !isNaN(parsedValue); + const hasPrecision= precision !== undefined; + const defaultValue: string | number = cellValue; + const isNumberType= typeof cellValue === "number"; + + if (isNumericType && hasValue && isNumber) { + const cellValAsNumber = Number(cellValue); + const isWholeNumber: boolean = cellValAsNumber % 1 === 0; + displayValue = isWholeNumber + ? parseInt(cellValue as string, 10) + : parsedValue.toFixed(hasPrecision ? precision : 2); + } else if (!isNumericType && isNumberType && hasValue) { + displayValue = (cellValue as number).toFixed(hasPrecision ? precision : 2); + } else { + displayValue = defaultValue; + } + + return ( + + {displayValue} + + ); +}; diff --git a/src/components/table-headers.tsx b/src/components/table-headers.tsx new file mode 100644 index 0000000..cdd31c1 --- /dev/null +++ b/src/components/table-headers.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { DraggagleTableHeader } from "./draggable-table-tags"; +import { IDataSet, Values } from "../types"; + +interface MapHeadersFromValuesProps { + collectionId: number; + rowKey: string; + values: Values; + attrVisibilities: Record; + isParent?: boolean; + attrId?: number; + editableHasFocus?: boolean; + selectedDataSet: IDataSet; + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise; +} + +export const TableHeaders: React.FC = ({ + collectionId, + rowKey, + values, + attrVisibilities, + isParent, + attrId, + editableHasFocus, + selectedDataSet, + renameAttribute +}) => { + return ( + <> + {Object.keys(values).map((key, index) => { + if (!attrVisibilities[key] && (typeof values[key] === "string" || typeof values[key] === "number")) { + return ( + + {key} + + ); + } + return null; + })} + + ); +}; diff --git a/src/components/tables.scss b/src/components/tables.scss index a0edc39..510f6c5 100644 --- a/src/components/tables.scss +++ b/src/components/tables.scss @@ -120,11 +120,21 @@ table { border-spacing: 0; .datasetNameHeader { + font-size: 13px; + padding: 3px 0; position: sticky; top: 0; z-index: 10; } + .parentCollHeader { + align-items: center; + display: flex; + font-size: 12px; + justify-content: center; + padding: 3px 0; + } + &.collection0, .collection0, &.collection0>tbody>tr>td { border: 1px solid #3c78d8; } @@ -254,7 +264,7 @@ table.draggableTableContainer { position: relative; .cellTextValue{ position: absolute; - width: fit-content; + width: 100%; // fit-content; display: flex; flex-wrap: wrap; // align-content: center; @@ -263,8 +273,8 @@ table.draggableTableContainer { } .draggable { - padding-right: 17px; - padding-left: 15px; + // padding-right: 17px; + // padding-left: 15px; } .headerMenu { @@ -284,6 +294,12 @@ table.draggableTableContainer { height: 15px; width: 100%; gap: 5px; + + button { + all: unset; + cursor: pointer; + } + .dropdownIcon { &:hover { cursor: pointer; @@ -292,6 +308,17 @@ table.draggableTableContainer { width: 10px; } } + + .spacer { + flex-grow: 1; + } + button:nth-child(4) { + height: 16px; + margin-top: 1px; + margin-left: 5px; + margin-left: 5px; + width: 16px; + } } @keyframes fadeInText { diff --git a/src/hooks/useCodapState.tsx b/src/hooks/useCodapState.tsx index 5398836..0bf8ab6 100644 --- a/src/hooks/useCodapState.tsx +++ b/src/hooks/useCodapState.tsx @@ -11,9 +11,12 @@ import { selectSelf, createCollectionFromAttribute, createNewCollection, + getAttribute, + updateAttribute, updateAttributePosition, + updateCaseById, } from "@concord-consortium/codap-plugin-api"; -import { getCases, getDataSetCollections, sortAttribute } from "../utils/apiHelpers"; +import { getCases, getCollectionById, getDataSetCollections, sortAttribute } from "../utils/apiHelpers"; import { ICollections, ICollection, IDataSet } from "../types"; const iFrameDescriptor = { @@ -46,8 +49,8 @@ export const useCodapState = () => { const [interactiveState, setInteractiveState] = useState({ view: null, dataSetName: null, - padding: false, - showHeaders: false, + padding: true, + showHeaders: true, displayMode: "" }); @@ -167,8 +170,8 @@ export const useCodapState = () => { const handleSelectDataSet = (name: string) => { - const selected = dataSets.find((d) => d.title === name); - return selected ? handleSetDataSet(selected.name) : handleSetDataSet(""); + const dataSetIdentifier = dataSets.find((d) => d.title === name || d.name === name)?.name; + return dataSetIdentifier ? handleSetDataSet(dataSetIdentifier) : handleSetDataSet(""); }; const getCollectionNameFromId = (id: number) => { @@ -258,6 +261,51 @@ export const useCodapState = () => { } }, [selectedDataSet]); + const handleUpdateInteractiveState = useCallback((update: Partial) => { + const newState = {...interactiveState, ...update}; + if (JSON.stringify(newState) !== JSON.stringify(interactiveState)) { + updateInteractiveState(newState); + } + }, [interactiveState, updateInteractiveState]); + + // const handleSelectDataSet = (name: string) => { + // const selected = dataSets.find((d) => d.title === name); + // if (selected) { + // handleSetDataSet(selected.name); + // handleUpdateInteractiveState({dataSetName: selected.name}); + // } else { + // handleSetDataSet(""); + // handleUpdateInteractiveState({dataSetName: null}); + // } + // }; + + const editCaseValue = async (newValue: string, caseId: string, attrTitle: string) => { + try { + await updateCaseById(selectedDataSetName, caseId, {[attrTitle]: newValue}); + } catch (e) { + console.error("Case not updated: ", e); + } + }; + + const addAttributeToCollection = async (collectionId: number, attrName: string) => { + try { + const collectionName = await getCollectionById(selectedDataSetName, collectionId); + await createNewAttribute(selectedDataSetName, collectionName, attrName); + } catch (e) { + console.error("Failed to add attribute to collection: ", e); + } + }; + + const renameAttribute = async (collectionName: string, attrId: number, oldName: string, newName: string) => { + const _attribute = await getAttribute(selectedDataSetName, collectionName, oldName); + const attribute = {..._attribute, name: oldName}; + try { + await updateAttribute(selectedDataSetName, collectionName, oldName, attribute, {name: newName}); + } catch (e) { + console.error("Failed to rename attribute: ", e); + } + }; + return { init, handleSelectSelf, @@ -279,6 +327,10 @@ export const useCodapState = () => { selectCODAPCases, listenForSelectionChanges, handleCreateCollectionFromAttribute, - handleUpdateCollections: updateCollections + handleUpdateCollections: updateCollections, + editCaseValue, + addAttributeToCollection, + handleUpdateInteractiveState, + renameAttribute }; }; diff --git a/src/types.ts b/src/types.ts index 1d1166b..81d1008 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,20 +58,24 @@ export interface ICollectionClass { } export interface ITableProps { - showHeaders: boolean, - collectionClasses: Array, - getClassName: (caseObj: IProcessedCaseObj) => string, - selectedDataSet: IDataSet, - collections: Array, + dataSetName: string; + showHeaders: boolean; + collectionClasses: Array; + getClassName: (caseObj: IProcessedCaseObj) => string; + selectedDataSet: IDataSet; + collections: Array; mapCellsFromValues: (collectionId: number, rowKey: string, caseValuesWithId: Values, precisions: Record, attrTypes: Record, attrVisibilities: Record, isParent?: boolean, resizeCounter?: number, - parentLevel?: number) => ReactNode | ReactNode[], - mapHeadersFromValues: (collectionId: number, rowKey: string, values: Values, - attrVisibilities: Record) => ReactNode | ReactNode[], - getValueLength: (firstRow: Array) => number - paddingStyle: Record - handleUpdateCollections: () => void + parentLevel?: number) => ReactNode | ReactNode[]; + mapHeadersFromValues?: (collectionId: number, rowKey: string, values: Values, + attrVisibilities: Record, + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise, + isParent?: boolean, attrId?: number, editableHasFocus?: boolean ) => ReactNode | ReactNode[]; + getValueLength: (firstRow: Array) => number; + paddingStyle: Record; + handleAddAttribute?: (collection: ICollection, attrName: string) => Promise; + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise; } export interface IBoundingBox { @@ -80,3 +84,37 @@ export interface IBoundingBox { width: number; height: number; } + +export interface InteractiveState { + view: "nested-table" | "hierarchy" | "card-view" | null + dataSetName: string|null; + padding: boolean; + showHeaders: boolean; + displayMode: string; +} + +export type CodapState = { + init: () => Promise, + handleSelectSelf: () => Promise, + dataSets: IDataSet[], + selectedDataSet: any, + collections: ICollections, + handleSetCollections: (collections: ICollections) => void, + handleSelectDataSet: (name: string) => void, + getCollectionNameFromId: (id: number) => string | undefined, + updateInteractiveState: (update: Partial) => void, + interactiveState: InteractiveState, + cases: any[], + connected: boolean, + handleUpdateAttributePosition: (coll: ICollection, attrName: string, position: number) => Promise, + handleAddCollection: (newCollectionName: string) => Promise, + handleSortAttribute: (context: string, attrId: number, isDescending: boolean) => Promise, + handleAddAttribute: (collection: ICollection, attrName: string) => Promise, + updateTitle: (title: string) => Promise, + selectCODAPCases: (caseIds: number[]) => Promise, + listenForSelectionChanges: (callback: (notification: any) => void) => void, + handleCreateCollectionFromAttribute: (collection: ICollection, attr: any, parent: number|string) => Promise, + handleUpdateCollections: () => Promise, + addAttributeToCollection: (collectionId: number, attrName: string) => Promise, + renameAttribute: (collectionName: string, attrId: number, oldName: string, newName: string) => Promise, +};