diff options
Diffstat (limited to 'src/client/views/collections')
6 files changed, 1473 insertions, 465 deletions
diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss index 6fb8e40db..c32661214 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss @@ -50,18 +50,15 @@ .schema-column-menu, .schema-filter-menu { background: $light-gray; - position: relative; - min-width: 200px; - max-width: 400px; + position: absolute; + border: 1px solid $medium-gray; + border-bottom: 2px solid $medium-gray; + max-height: 201px; display: flex; + overflow: hidden; flex-direction: column; align-items: flex-start; - z-index: 1; - - .schema-key-search-input { - width: calc(100% - 20px); - margin: 10px; - } + z-index: 5; .schema-search-result { cursor: pointer; @@ -104,7 +101,7 @@ .schema-key-list { width: 100%; - max-height: 300px; + max-height: 250px; overflow-y: auto; } @@ -153,12 +150,18 @@ padding: 0; z-index: 1; border: 1px solid $medium-gray; - //overflow: hidden; .schema-column-title { flex-grow: 2; margin: 5px; overflow: hidden; + min-width: 100%; + } + + .schema-column-edit-wrapper { + flex-grow: 2; + margin: 5px; + overflow: hidden; min-width: 20%; } @@ -176,6 +179,11 @@ } } + .editableView-input { + border: none; + outline: none; + } + /*.schema-column-resizer.left { min-width: 5px; transform: translate(-3px, 0px); @@ -245,9 +253,6 @@ flex-direction: row; min-width: 50px; justify-content: center; - .iconButton-container { - min-width: unset !important; - } } .row-cells { @@ -255,6 +260,20 @@ flex-direction: row; justify-content: flex-end; } + + .row-menu-infos { + position: absolute; + top: 3; + left: 3; + z-index: 1; + display: flex; + justify-content: flex-end; + align-items: center; + + .row-infos-icon { + padding-right: 2px; + } + } } .schema-row-button, @@ -287,3 +306,9 @@ width: 12px; } } + +.schemaField-editing { + outline: none; +} + + diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx index 325628d53..0076caaf8 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -1,12 +1,12 @@ /* eslint-disable no-restricted-syntax */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Popup, PopupTrigger, Type } from 'browndash-components'; -import { ObservableMap, action, computed, makeObservable, observable, observe, runInAction } from 'mobx'; +import { IconButton, Popup, PopupTrigger, Size, Type } from 'browndash-components'; +import { IReactionDisposer, ObservableMap, action, autorun, computed, makeObservable, observable, observe, override, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { returnEmptyString, returnFalse, returnIgnore, returnNever, returnTrue, setupMoveUpEvents, smoothScroll } from '../../../../ClientUtils'; +import { ClientUtils, returnEmptyString, returnFalse, returnIgnore, returnNever, returnTrue, setupMoveUpEvents, smoothScroll } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc, DocListCast, Field, FieldType, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, FieldType, IdToDoc, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; @@ -16,7 +16,6 @@ import { DocUtils } from '../../../documents/DocUtils'; import { Docs, DocumentOptions, FInfo } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; import { dropActionType } from '../../../util/DropActionTypes'; -import { SettingsManager } from '../../../util/SettingsManager'; import { undoBatch, undoable } from '../../../util/UndoManager'; import { ContextMenu } from '../../ContextMenu'; import { EditableView } from '../../EditableView'; @@ -31,6 +30,22 @@ import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView' import './CollectionSchemaView.scss'; import { SchemaColumnHeader } from './SchemaColumnHeader'; import { SchemaRowBox } from './SchemaRowBox'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { DocumentManager } from '../../../util/DocumentManager'; +import { SchemaCellField } from './SchemaCellField'; +import { SnappingManager } from '../../../util/SnappingManager'; + +/** + * The schema view offers a spreadsheet-like interface for users to interact with documents. Within the schema, + * each doc is represented by its own row. Each column represents a field, for example the author or title fields. + * Users can apply varoius filters and sorts to columns to change what is displayed. The schemaview supports equations for + * cell linking. + * + * This class supports the main functionality for choosing which docs to render in the view, applying visual + * updates to rows and columns (such as user dragging or sort-related highlighting), applying edits to multiple cells + * at once, and applying filters and sorts to columns. It contains SchemaRowBoxes (which themselves contain SchemaTableCells, + * and SchemaCellFields) and SchemaColumnHeaders. + */ const { SCHEMA_NEW_NODE_HEIGHT } = require('../../global/globalCssVariables.module.scss'); // prettier-ignore @@ -48,16 +63,31 @@ const defaultColumnKeys: string[] = ['title', 'type', 'author', 'author_date', ' @observer export class CollectionSchemaView extends CollectionSubView() { - private _keysDisposer?: () => void; + private _keysDisposer: any; + private _disposers: { [name: string]: IReactionDisposer } = {}; private _previewRef: HTMLDivElement | null = null; private _makeNewColumn: boolean = false; private _documentOptions: DocumentOptions = new DocumentOptions(); private _tableContentRef: HTMLDivElement | null = null; private _menuTarget = React.createRef<HTMLDivElement>(); + private _headerRefs: SchemaColumnHeader[] = []; + private _eqHighlightColors: Array<[{r: number, g: number, b: number}, {r: number, g: number, b: number}]> = []; constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); + const lightenedColor = (r: number, g: number, b:number) => { const lightened = ClientUtils.lightenRGB(r, g, b, 165); return {r: lightened[0], g: lightened[1], b: lightened[2]}} // prettier-ignore + const colors = (r: number, g: number, b: number): [any, any] => {return [{r: r, g: g, b: b}, lightenedColor(r, g, b)]} // prettier-ignore + this._eqHighlightColors.push(colors(70, 150, 50)); + this._eqHighlightColors.push(colors(180, 70, 20)); + this._eqHighlightColors.push(colors(70, 50, 150)); + this._eqHighlightColors.push(colors(0, 140, 140)); + this._eqHighlightColors.push(colors(140, 30, 110)); + this._eqHighlightColors.push(colors(20, 50, 200)); + this._eqHighlightColors.push(colors(210, 30, 40)); + this._eqHighlightColors.push(colors(120, 130, 30)); + this._eqHighlightColors.push(colors(50, 150, 70)); + this._eqHighlightColors.push(colors(10, 90, 180)); } static _rowHeight: number = 50; @@ -79,15 +109,21 @@ export class CollectionSchemaView extends CollectionSubView() { @observable _newFieldType: ColumnType = ColumnType.Number; @observable _menuValue: string = ''; @observable _filterColumnIndex: number | undefined = undefined; - @observable _filterSearchValue: string = ''; + @observable _filterSearchValue: string = ''; //the current text inside the filter search bar, used to determine which values to display @observable _selectedCol: number = 0; @observable _selectedCells: Array<Doc> = []; - @observable _mouseCoordinates = { x: 0, y: 0 }; - @observable _lowestSelectedIndex = -1; // lowest index among selected rows; used to properly sync dragged docs with cursor position - @observable _relCursorIndex = -1; // cursor index relative to the current selected cells - @observable _draggedColIndex = 0; - @observable _colBeingDragged = false; - + @observable _mouseCoordinates = { x: 0, y: 0, prevX: 0, prevY: 0 }; + @observable _lowestSelectedIndex: number = -1; //lowest index among selected rows; used to properly sync dragged docs with cursor position + @observable _relCursorIndex: number = -1; //cursor index relative to the current selected cells + @observable _draggedColIndex: number = 0; + @observable _colBeingDragged: boolean = false; //whether a column is being dragged by the user + @observable _colKeysFiltered: boolean = false; + @observable _cellTags: ObservableMap = new ObservableMap<Doc, Array<string>>(); + @observable _highlightedCellsInfo: Array<[doc: Doc, field: string]> = []; + @observable _cellHighlightColors: ObservableMap = new ObservableMap<string, string[]>(); + @observable _containedDocs: Doc[] = []; //all direct children of the schema + @observable _referenceSelectMode: {enabled: boolean, currEditing: SchemaCellField | undefined} = {enabled: false, currEditing: undefined} + // target HTMLelement portal for showing a popup menu to edit cell values. public get MenuTarget() { return this._menuTarget.current; @@ -95,7 +131,8 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get _selectedDocs() { // get all selected documents then filter out any whose parent is not this schema document - const selected = DocumentView.SelectedDocs().filter(doc => this.childDocs.includes(doc)); + const selected = DocumentView.SelectedDocs().filter(doc => this.docs.includes(doc)); + //&& this._selectedCells.includes(doc) if (!selected.length) { // if no schema doc is directly selected, test if a child of a schema doc is selected (such as in the preview window) const childOfSchemaDoc = DocumentView.SelectedDocs().find(sel => DocumentView.getContextPath(sel, true).includes(this.Document)); @@ -107,6 +144,10 @@ export class CollectionSchemaView extends CollectionSubView() { return selected; } + @computed get highlightedCells() { + return this._highlightedCellsInfo.map(info => this.getCellElement(info[0], info[1])); + } + @computed get documentKeys() { return Array.from(this.fieldInfos.keys()); } @@ -130,7 +171,6 @@ export class CollectionSchemaView extends CollectionSubView() { ); const totalWidth = widths.reduce((sum, width) => sum + width, 0); - // If the total width of all columns is not the width of the schema table minus the width of the row menu, resize them appropriately if (totalWidth !== this.tableWidth - CollectionSchemaView._rowMenuWidth) { return widths.map(w => (w / totalWidth) * (this.tableWidth - CollectionSchemaView._rowMenuWidth)); } @@ -138,7 +178,7 @@ export class CollectionSchemaView extends CollectionSubView() { } @computed get rowHeights() { - return this.childDocs.map(() => this.rowHeightFunc()); + return this.docs.map(() => this.rowHeightFunc()); } @computed get displayColumnWidths() { @@ -176,17 +216,33 @@ export class CollectionSchemaView extends CollectionSubView() { }, true ); + this._disposers.docdata = reaction( + () => DocListCast(this.dataDoc[this.fieldKey]), + (docs) => this._containedDocs = docs, + {fireImmediately: true} + ) + this._disposers.sortHighlight = reaction( + () => [this.sortField, this._containedDocs, this._selectedDocs, this._highlightedCellsInfo], + () => {this.sortField && setTimeout(() => this.highlightSortedColumn())}, + {fireImmediately: true} + ) } componentWillUnmount() { this._keysDisposer?.(); + Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener('keydown', this.onKeyDown); } // ViewBoxInterface overrides override isUnstyledView = returnTrue; // used by style provider : turns off opacity, animation effects, scaling - rowIndex = (doc: Doc) => this.sortedDocs.docs.indexOf(doc); + removeDoc = (doc: Doc) => { + this.removeDocument(doc); + this._containedDocs = this._containedDocs.filter(d => d !== doc) + } + + rowIndex = (doc: Doc) => this.docsWithDrag.docs.indexOf(doc); @action onKeyDown = (e: KeyboardEvent) => { @@ -196,9 +252,9 @@ export class CollectionSchemaView extends CollectionSubView() { { const lastDoc = this._selectedDocs.lastElement(); const lastIndex = this.rowIndex(lastDoc); - const curDoc = this.sortedDocs.docs[lastIndex]; + const curDoc = this.docs[lastIndex]; if (lastIndex >= 0 && lastIndex < this.childDocs.length - 1) { - const newDoc = this.sortedDocs.docs[lastIndex + 1]; + const newDoc = this.docs[lastIndex + 1]; if (this._selectedDocs.includes(newDoc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(curDoc)); this.deselectCell(curDoc); @@ -215,9 +271,9 @@ export class CollectionSchemaView extends CollectionSubView() { { const firstDoc = this._selectedDocs.lastElement(); const firstIndex = this.rowIndex(firstDoc); - const curDoc = this.sortedDocs.docs[firstIndex]; + const curDoc = this.docs[firstIndex]; if (firstIndex > 0 && firstIndex < this.childDocs.length) { - const newDoc = this.sortedDocs.docs[firstIndex - 1]; + const newDoc = this.docs[firstIndex - 1]; if (this._selectedDocs.includes(newDoc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(curDoc)); this.deselectCell(curDoc); @@ -245,34 +301,26 @@ export class CollectionSchemaView extends CollectionSubView() { } break; case 'Backspace': { - undoable(() => this.removeDocument(this._selectedDocs), 'delete schema row'); + undoable(() => {this._selectedDocs.forEach(d => this._containedDocs.includes(d) && this.removeDoc(d));}, 'delete schema row'); break; } case 'Escape': { this.deselectAllCells(); break; } + case 'P': { + break; + } default: } } }; - @action - changeSelectedCellColumn = () => {}; - - @undoBatch - setColumnSort = (field: string | undefined, desc: boolean = false) => { - this.layoutDoc.sortField = field; - this.layoutDoc.sortDesc = desc; - }; - addRow = (doc: Doc | Doc[]) => this.addDocument(doc); @undoBatch - changeColumnKey = (index: number, newKey: string, defaultVal?: string | number | boolean) => { - if (!this.documentKeys.includes(newKey)) { - this.addNewKey(newKey, defaultVal); - } + changeColumnKey = (index: number, newKey: string, defaultVal?: any) => { + if (!this.documentKeys.includes(newKey)) this.addNewKey(newKey, defaultVal); const currKeys = this.columnKeys.slice(); // copy the column key array first, then change it. currKeys[index] = newKey; @@ -280,31 +328,36 @@ export class CollectionSchemaView extends CollectionSubView() { }; @undoBatch - addColumn = (key: string, defaultVal?: string | number | boolean) => { - if (!this.documentKeys.includes(key)) { - this.addNewKey(key, defaultVal); - } - + addColumn = (index: number = 0, key?: string, defaultVal?: any) => { + if (key && !this.documentKeys.includes(key)) this.addNewKey(key, defaultVal); + const newColWidth = this.tableWidth / (this.storedColumnWidths.length + 1); const currWidths = this.storedColumnWidths.slice(); - currWidths.splice(0, 0, newColWidth); + currWidths.splice(index, 0, newColWidth); const newDesiredTableWidth = currWidths.reduce((w, cw) => w + cw, 0); this.layoutDoc.schema_columnWidths = new List<number>(currWidths.map(w => (w / newDesiredTableWidth) * (this.tableWidth - CollectionSchemaView._rowMenuWidth))); const currKeys = this.columnKeys.slice(); - currKeys.splice(0, 0, key); + if (!key) key = 'EmptyColumnKey' + Math.floor(Math.random() * 1000000000000000).toString(); + currKeys.splice(index, 0, key); + this.changeColumnKey(index, 'EmptyColumnKey' + Math.floor(Math.random() * 1000000000000000).toString()); this.layoutDoc.schema_columnKeys = new List<string>(currKeys); }; @action - addNewKey = (key: string, defaultVal?: string | number | boolean) => + addNewKey = (key: string, defaultVal: any) => { this.childDocs.forEach(doc => { doc[DocData][key] = defaultVal; }); + } @undoBatch removeColumn = (index: number) => { if (this.columnKeys.length === 1) return; + if (this._columnMenuIndex === index) { + this._headerRefs[index].toggleEditing(false); + this.closeNewColumnMenu(); + } const currWidths = this.storedColumnWidths.slice(); currWidths.splice(index, 1); const newDesiredTableWidth = currWidths.reduce((w, cw) => w + cw, 0); @@ -312,24 +365,29 @@ export class CollectionSchemaView extends CollectionSubView() { const currKeys = this.columnKeys.slice(); currKeys.splice(index, 1); - this.layoutDoc.schema_columnKeys = new List<string>(currKeys); + this.layoutDoc.schema_columnKeys = new List<string>(currKeys); + + this._colEles.splice(index, 1); }; @action - startResize = (e: React.PointerEvent, index: number) => { + startResize = (e: any, index: number, rightSide: boolean) => { this._displayColumnWidths = this.storedColumnWidths; - setupMoveUpEvents(this, e, moveEv => this.resizeColumn(moveEv, index), this.finishResize, emptyFunction); + setupMoveUpEvents(this, e, moveEv => this.resizeColumn(moveEv, index, rightSide), this.finishResize, emptyFunction); }; @action - resizeColumn = (e: PointerEvent, index: number) => { + resizeColumn = (e: PointerEvent, index: number, rightSide: boolean) => { if (this._displayColumnWidths) { let shrinking; let growing; let change = e.movementX; - if (index !== 0) { + if (rightSide && (index !== this._displayColumnWidths.length - 1)) { + growing = change < 0 ? index + 1: index; + shrinking = change < 0 ? index : index + 1; + } else if (index !== 0) { growing = change < 0 ? index : index - 1; shrinking = change < 0 ? index - 1 : index; } @@ -367,14 +425,14 @@ export class CollectionSchemaView extends CollectionSubView() { const currWidths = this.storedColumnWidths.slice(); currWidths.splice(toIndex, 0, currWidths.splice(fromIndex, 1)[0]); this.layoutDoc.schema_columnWidths = new List<number>(currWidths); - - this._draggedColIndex = toIndex; }; @action dragColumn = (e: PointerEvent, index: number) => { + this.closeNewColumnMenu(); + this._headerRefs.forEach(ref => ref.toggleEditing(false)); this._draggedColIndex = index; - this._colBeingDragged = true; + this.setColDrag(true); const dragData = new DragManager.ColumnDragData(index); const dragEles = [this._colEles[index]]; this.childDocs.forEach(doc => dragEles.push(this._rowEles.get(doc).children[1].children[index])); @@ -382,7 +440,13 @@ export class CollectionSchemaView extends CollectionSubView() { return true; }; + /** + * Uses cursor x coordinate to calculate which index the column should be rendered/dropped in + * @param mouseX cursor x coordinate + * @returns column index + */ findColDropIndex = (mouseX: number) => { + const xOffset: number = this._props.ScreenToLocalTransform().inverse().transformPoint(0,0)[0] + CollectionSchemaView._rowMenuWidth; let index: number | undefined; this.displayColumnWidths.reduce((total, curr, i) => { if (total <= mouseX && total + curr >= mouseX) { @@ -390,16 +454,35 @@ export class CollectionSchemaView extends CollectionSubView() { else index = i + 1; } return total + curr; - }, 2 * CollectionSchemaView._rowMenuWidth); // probably prone to issues; find better implementation (!!!) + }, xOffset); return index; }; /** - * Calculates the relative index of the cursor in the group of selected rows, ie. - * if five rows are selected and the cursor is in the middle row, its relative index would be 2. - * Used to align actively dragged documents properly with the cursor. - * @param mouseY the initial Y position of the cursor on drag + * Calculates the current index of dragged rows for dynamic rendering and drop logic. + * @param mouseY user's cursor position relative to the viewport + * @returns row index the dragged doc should be rendered/dropped in */ + findRowDropIndex = (mouseY: number): number => { + const rowHeight = CollectionSchemaView._rowHeight; + let index: number = 0; + this.rowHeights.reduce((total, curr, i) => { + if (total <= mouseY && total + curr >= mouseY) { + if (mouseY <= total + curr) index = i; + else index = i + 1; + } + return total + curr; + }, rowHeight); + + // fix index if selected rows are dragged out of bounds + let adjIndex = index - this._relCursorIndex; + const maxY = this.rowHeights.reduce((total, curr) => total + curr, 0) + rowHeight; + if (mouseY > maxY) adjIndex = this.childDocs.length - 1; + else if (adjIndex <= 0) adjIndex = 0; + + return adjIndex; + }; + @action setRelCursorIndex = (mouseY: number) => { this._mouseCoordinates.y = mouseY; // updates this.rowDropIndex computed value to overwrite the old cached value @@ -420,43 +503,196 @@ export class CollectionSchemaView extends CollectionSubView() { this._relCursorIndex = index; }; - findRowDropIndex = (mouseY: number) => { - const rowHeight = CollectionSchemaView._rowHeight; - let index: number = 0; - this.rowHeights.reduce((total, curr, i) => { - if (total <= mouseY && total + curr >= mouseY) { - if (mouseY <= total + curr) index = i; - else index = i + 1; - } - return total + curr; - }, rowHeight); - - // fix index if selected rows are dragged out of bounds - let adjIndex = index - this._relCursorIndex; - const maxY = this.rowHeights.reduce((total, curr) => total + curr, 0) + rowHeight; - if (mouseY > maxY) adjIndex = this.childDocs.length - 1; - else if (adjIndex <= 0) adjIndex = 0; - - return adjIndex; - }; - highlightDraggedColumn = (index: number) => this._colEles.forEach((colRef, i) => { const edgeStyle = i === index ? `solid 2px ${Colors.MEDIUM_BLUE}` : ''; + const sorted = i === this.columnKeys.indexOf(this.sortField); const cellEles = [ colRef, - ...this.childDocs // - .filter(doc => i !== this._selectedCol || !this._selectedDocs.includes(doc)) + ...this.docsWithDrag.docs + .filter(doc => (i !== this._selectedCol || !this._selectedDocs.includes(doc)) && !sorted) .map(doc => this._rowEles.get(doc).children[1].children[i]), ]; - cellEles[0].style.borderTop = edgeStyle; cellEles.forEach(ele => { + if (sorted || this.highlightedCells.includes(ele)) return; + ele.style.borderTop = ele === cellEles[0] ? edgeStyle : ''; ele.style.borderLeft = edgeStyle; ele.style.borderRight = edgeStyle; + ele.style.borderBottom = ele === cellEles.slice(-1)[0] ? edgeStyle : ''; }); - cellEles.slice(-1)[0].style.borderBottom = edgeStyle; }); + removeDragHighlight = () => { + this._colEles.forEach((colRef, i) => { + const sorted = i === this.columnKeys.indexOf(this.sortField); + if (sorted) return; + + colRef.style.borderLeft = ''; + colRef.style.borderRight = ''; + colRef.style.borderTop = ''; + + this.childDocs.forEach(doc => { + const cell = this._rowEles.get(doc).children[1].children[i]; + if (!(this._selectedDocs.includes(doc) && i === this._selectedCol) && !(this.highlightedCells.includes(cell)) && cell) { + cell.style.borderLeft = ''; + cell.style.borderRight = ''; + cell.style.borderBottom = ''; + } + }); + }); + } + + /** + * Applies a gradient highlight to a sorted column. The direction of the gradient depends + * on whether the sort is ascending or descending. + * @param field the column being sorted + * @param descending whether the sort is descending or ascending; descending if true + */ + highlightSortedColumn = (field?: string, descending?: boolean) => { + let index = -1; + const highlightColors: string[] = []; + const rowCount: number = this._containedDocs.length + 1; + if (field || this.sortField){ + index = this.columnKeys.indexOf(field || this.sortField); + const increment: number = 110/rowCount; + for (let i = 1; i <= rowCount; ++i){ + const adjColor = ClientUtils.lightenRGB(16, 66, 230, increment * i); + highlightColors.push(`solid 2px rgb(${adjColor[0]}, ${adjColor[1]}, ${adjColor[2]})`); + } + } + + this._colEles.forEach((colRef, i) => { + const highlight: boolean = i === index; + const desc: boolean = descending || this.sortDesc; + const cellEles = [ + colRef, + ...this.docsWithDrag.docs + .filter(doc => (i !== this._selectedCol || !this._selectedDocs.includes(doc))) + .map(doc => this._rowEles.get(doc).children[1].children[i]), + ]; + const cellCount = cellEles.length; + for (let ele = 0; ele < cellCount; ++ele){ + const currCell = cellEles[ele]; + if (this.highlightedCells.includes(currCell)) continue; + const style = highlight ? desc ? `${highlightColors[cellCount - 1 - ele]}` : `${highlightColors[ele]}` : ''; + currCell.style.borderLeft = style; + currCell.style.borderRight = style; + } + cellEles[0].style.borderTop = highlight ? desc ? `${highlightColors[cellCount - 1]}` : `${highlightColors[0]}` : ''; + if (!(this._selectedDocs.includes(this.docsWithDrag.docs[this.docsWithDrag.docs.length - 1]) && this._selectedCol === index) && !this.highlightedCells.includes(cellEles[cellCount - 1])) cellEles[cellCount - 1].style.borderBottom = highlight ? desc ? `${highlightColors[0]}` : `${highlightColors[cellCount - 1]}` : ''; + }); + + } + + /** + * Gets the html element representing a cell so that styles can be applied on it. + * @param doc the cell's row document + * @param fieldKey the cell's column's field key + * @returns the html element representing the cell at the given location + */ + getCellElement = (doc: Doc, fieldKey: string) => { + const index = this.columnKeys.indexOf(fieldKey); + const cell = this._rowEles.get(doc).children[1].children[index]; + return cell; + } + + /** + * Given text in a cell, find references to other cells (for equations). + * @param text the text in the cell + * @returns the html cell elements referenced in the text. + */ + findCellRefs = (text: string) => { + const pattern = /(this|d(\d+))\.(\w+)/g; + interface Match { docRef: string; field: string; } + + const matches: Match[] = []; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + const docRef = match[1] === 'this' ? match[1] : match[2]; + matches.push({ docRef, field: match[3] }); + } + + const cells: Array<any> = []; + matches.forEach((match: Match) => { + const {docRef, field} = match; + const docView = DocumentManager.Instance.DocumentViews[Number(docRef)]; + const doc = docView?.Document ?? undefined; + if (this.columnKeys.includes(field) && this._containedDocs.includes(doc)) {cells.push([doc, field])} + }) + + return cells; + } + + /** + * Determines whether the rows above or below a given row have been + * selected, so selection highlights don't overlap. + * @param doc the document row to check + * @returns a boolean tuple where 0 is the row above, and 1 is the row below + */ + selectionOverlap = (doc: Doc): [boolean, boolean] => { + const docs = this.docsWithDrag.docs; + const index = this.rowIndex(doc); + const selectedBelow: boolean = this._selectedDocs.includes(docs[index + 1]); + const selectedAbove: boolean = this._selectedDocs.includes(docs[index - 1]); + return [selectedAbove, selectedBelow]; + } + + @action + removeCellHighlights = () => { + this._highlightedCellsInfo.forEach(info => { + const doc = info[0]; + const field = info[1]; + const cell = this.getCellElement(doc, field); + if (this._selectedDocs.includes(doc) && this._selectedCol === this.columnKeys.indexOf(field)) { + cell.style.border = `solid 2px ${Colors.MEDIUM_BLUE}`; + if (this.selectionOverlap(doc)[0]) cell.style.borderTop = ''; + if (this.selectionOverlap(doc)[1]) cell.style.borderBottom = ''; + } else cell.style.border = ''; + cell.style.backgroundColor = '';}); + this._highlightedCellsInfo = []; + } + + restoreCellHighlights = () => { + this._highlightedCellsInfo.forEach(info => { + const doc = info[0]; + const field = info[1]; + const key = `${doc[Id]}_${field}`; + const cell = this.getCellElement(doc, field); + const color = this._cellHighlightColors.get(key)[0]; + cell.style.borderTop = color; + cell.style.borderLeft = color; + cell.style.borderRight = color; + cell.style.borderBottom = color; + }); + } + + /** + * Highlights cells based on equation text in the cell currently being edited. + * Does not highlight selected cells (that's done directly in SchemaTableCell). + * @param text the equation + */ + highlightCells = (text: string) => { + this.removeCellHighlights(); + + const cellsToHighlight = this.findCellRefs(text); + this._highlightedCellsInfo = [...cellsToHighlight]; + + for (let i = 0; i < this._highlightedCellsInfo.length; ++i) { + const info = this._highlightedCellsInfo[i]; + const color = this._eqHighlightColors[i % 10]; + const colorStrings = [`solid 2px rgb(${color[0].r}, ${color[0].g}, ${color[0].b})`, `rgb(${color[1].r}, ${color[1].g}, ${color[1].b})`]; + const doc = info[0]; + const field = info[1]; + const key = `${doc[Id]}_${field}`; + const cell = this.getCellElement(doc, field); + this._cellHighlightColors.set(key, [colorStrings[0], colorStrings[1]]); + cell.style.border = colorStrings[0]; + cell.style.backgroundColor = colorStrings[1]; + } + } + + //Used in SchemaRowBox @action addRowRef = (doc: Doc, ref: HTMLDivElement) => this._rowEles.set(doc, ref); @@ -477,33 +713,48 @@ export class CollectionSchemaView extends CollectionSubView() { @action clearSelection = () => { + if (this._referenceSelectMode.enabled) return; DocumentView.DeselectAll(); this.deselectAllCells(); }; - selectRows = (doc: Doc, lastSelected: Doc) => { + + selectRow = (doc: Doc, lastSelected: Doc) => { const index = this.rowIndex(doc); const lastSelectedRow = this.rowIndex(lastSelected); const startRow = Math.min(lastSelectedRow, index); const endRow = Math.max(lastSelectedRow, index); for (let i = startRow; i <= endRow; i++) { - const currDoc = this.sortedDocs.docs[i]; + const currDoc = this.docsWithDrag.docs[i]; if (!this._selectedDocs.includes(currDoc)) { this.selectCell(currDoc, this._selectedCol, false, true); } } }; + //Used in SchemaRowBox + selectReference = (doc: Doc | undefined, col: number) => { + if (!doc) return; + const docIndex = DocumentView.getDocViewIndex(doc); + const field = this.columnKeys[col]; + const refToAdd = `d${docIndex}.${field}` + const editedField = this._referenceSelectMode.currEditing ? this._referenceSelectMode.currEditing as SchemaCellField : null; + editedField?.insertText(refToAdd, true); + editedField?.setupRefSelect(false); + return; + } + @action selectCell = (doc: Doc, col: number, shiftKey: boolean, ctrlKey: boolean) => { + this.closeNewColumnMenu(); if (!shiftKey && !ctrlKey) this.clearSelection(); !this._selectedCells && (this._selectedCells = []); - !shiftKey && this._selectedCells && this._selectedCells.push(doc); + !shiftKey && this._selectedCells.push(doc); const index = this.rowIndex(doc); if (!this) return; const lastSelected = Array.from(this._selectedDocs).lastElement(); - if (shiftKey && lastSelected && !this._selectedDocs.includes(doc)) this.selectRows(doc, lastSelected); + if (shiftKey && lastSelected && !this._selectedDocs.includes(doc)) this.selectRow(doc, lastSelected); else if (ctrlKey) { if (lastSelected && this._selectedDocs.includes(doc)) { DocumentView.DeselectView(DocumentView.getFirstDocumentView(doc)); @@ -513,8 +764,6 @@ export class CollectionSchemaView extends CollectionSubView() { this._selectedCol = col; if (this._lowestSelectedIndex === -1 || index < this._lowestSelectedIndex) this._lowestSelectedIndex = index; - - // let selectedIndexes: Array<Number> = this._selectedCells.map(doc => this.rowIndex(doc)); }; @action @@ -529,41 +778,24 @@ export class CollectionSchemaView extends CollectionSubView() { this._lowestSelectedIndex = -1; }; - sortedSelectedDocs = () => this.sortedDocs.docs.filter(doc => this._selectedDocs.includes(doc)); - @computed get rowDropIndex() { const mouseY = this.ScreenToLocalBoxXf().transformPoint(this._mouseCoordinates.x, this._mouseCoordinates.y)[1]; return this.findRowDropIndex(mouseY); } + @action onInternalDrop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.columnDragData) { - this._colBeingDragged = false; + setTimeout(() => {this.setColDrag(false);}); e.stopPropagation(); - - this._colEles.forEach((colRef, i) => { - // style for menu cell - colRef.style.borderLeft = ''; - colRef.style.borderRight = ''; - colRef.style.borderTop = ''; - - this.childDocs.forEach(doc => { - if (!(this._selectedDocs.includes(doc) && i === this._selectedCol)) { - this._rowEles.get(doc).children[1].children[i].style.borderLeft = ''; - this._rowEles.get(doc).children[1].children[i].style.borderRight = ''; - this._rowEles.get(doc).children[1].children[i].style.borderBottom = ''; - } - }); - }); return true; } const draggedDocs = de.complete.docDragData?.draggedDocuments; if (draggedDocs && super.onInternalDrop(e, de) && !this.sortField) { - const map = draggedDocs?.map(doc => this.rowIndex(doc)); - console.log(map); - this.dataDoc[this.fieldKey ?? 'data'] = new List<Doc>([...this.sortedDocs.docs]); + const docs = this.docsWithDrag.docs.slice(); + this.dataDoc[this.fieldKey ?? 'data'] = new List<Doc>([...docs]); this.clearSelection(); draggedDocs.forEach(doc => { DocumentView.addViewRenderedCb(doc, dv => dv.select(true)); @@ -616,119 +848,44 @@ export class CollectionSchemaView extends CollectionSubView() { return undefined; }; - @computed get fieldDefaultInput() { - switch (this._newFieldType) { - case ColumnType.Number: - return ( - <input - type="number" - name="" - id="" - value={Number(this._newFieldDefault ?? 0)} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.value; - })} - /> - ); - case ColumnType.Boolean: - return ( - <> - <input - type="checkbox" - value={this._newFieldDefault?.toString()} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.checked; - })} - /> - {this._newFieldDefault ? 'true' : 'false'} - </> - ); - case ColumnType.String: - return ( - <input - type="text" - name="" - id="" - value={this._newFieldDefault?.toString() ?? ''} - onPointerDown={e => e.stopPropagation()} - onChange={action(e => { - this._newFieldDefault = e.target.value; - })} - /> - ); - default: - return undefined; - } - } - - onSearchKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'Enter': - this._menuKeys.length > 0 && this._menuValue.length > 0 - ? this.setKey(this._menuKeys[0]) - : runInAction(() => { - this._makeNewField = true; - }); - break; - case 'Escape': - this.closeColumnMenu(); - break; - default: - } - }; - @action - setKey = (key: string, defaultVal?: string | number | boolean) => { + setKey = (key: string, defaultVal?: any, index?: number) => { + if (this.columnKeys.includes(key)) return; + if (this._makeNewColumn) { - this.addColumn(key, defaultVal); - } else { - this.changeColumnKey(this._columnMenuIndex!, key, defaultVal); - } - this.closeColumnMenu(); - }; + this.addColumn(this.columnKeys.indexOf(key), key, defaultVal); + this._makeNewColumn = false; + } else this.changeColumnKey(this._columnMenuIndex! | index!, key, defaultVal); - setColumnValues = (key: string, value: string) => { - const selectedDocs: Doc[] = []; - this.childDocs.forEach(doc => { - const docIsSelected = this._selectedCells && !(this._selectedCells?.filter(d => d === doc).length === 0); - if (docIsSelected) { - selectedDocs.push(doc); - } - }); - if (selectedDocs.length === 1) { - this.childDocs.forEach(doc => Doc.SetField(doc, key, value)); - } else { - selectedDocs.forEach(doc => Doc.SetField(doc, key, value)); - } - return true; + this.closeNewColumnMenu(); }; - setSelectedColumnValues = (key: string, value: string) => { - this.childDocs.forEach(doc => { - const docIsSelected = this._selectedCells && !(this._selectedCells?.filter(d => d === doc).length === 0); - if (docIsSelected) { - Doc.SetField(doc, key, value); - } - }); + /** + * Used in SchemaRowBox to set + * @param key + * @param value + * @returns + */ + setCellValues = (key: string, value: string) => { + if (this._selectedCells.length === 1) this.docs.forEach(doc => !doc._lockedSchemaEditing && Doc.SetField(doc, key, value)); + else this._selectedCells.forEach(doc => !doc._lockedSchemaEditing && Doc.SetField(doc, key, value)); return true; }; @action - openColumnMenu = (index: number, newCol: boolean) => { + openNewColumnMenu = (index: number, newCol: boolean) => { + this.closeFilterMenu(); + this._makeNewColumn = false; this._columnMenuIndex = index; this._menuValue = ''; this._menuKeys = this.documentKeys; - this._makeNewField = false; this._newFieldWarning = ''; - this._makeNewField = false; this._makeNewColumn = newCol; }; @action - closeColumnMenu = () => { + closeNewColumnMenu = () => { this._columnMenuIndex = undefined; }; @@ -743,32 +900,110 @@ export class CollectionSchemaView extends CollectionSubView() { this._filterColumnIndex = undefined; }; + @undoBatch + setColumnSort = (field: string | undefined, desc: boolean = false) => { + this.layoutDoc.sortField = field; + this.layoutDoc.sortDesc = desc; + }; + openContextMenu = (x: number, y: number, index: number) => { - this.closeColumnMenu(); + this.closeNewColumnMenu(); this.closeFilterMenu(); - ContextMenu.Instance.clearItems(); - ContextMenu.Instance.addItem({ - description: 'Change field', - event: () => this.openColumnMenu(index, false), + const cm = ContextMenu.Instance; + cm.clearItems(); + + const fieldSortedAsc = (this.sortField === this.columnKeys[index] && !this.sortDesc); + const fieldSortedDesc = (this.sortField === this.columnKeys[index] && this.sortDesc); + const revealOptions = cm.findByDescription('Sort column') + const sortOptions: ContextMenuProps[] = revealOptions && revealOptions && 'subitems' in revealOptions ? revealOptions.subitems ?? [] : []; + sortOptions.push({ + description: 'Sort A-Z', + event: () => { + this.setColumnSort(undefined); + const field = this.columnKeys[index]; + this._containedDocs = this.sortDocs(field, false); + setTimeout(() => { + this.highlightSortedColumn(field, false); + setTimeout(() => this.highlightSortedColumn(), 480); + }, 20); + }, + icon: 'arrow-down-a-z',}); + sortOptions.push({ + description: 'Sort Z-A', + event: () => { + this.setColumnSort(undefined); + const field = this.columnKeys[index]; + this._containedDocs = this.sortDocs(field, true); + setTimeout(() => { + this.highlightSortedColumn(field, true); + setTimeout(() => this.highlightSortedColumn(), 480); + }, 20); + }, + icon: 'arrow-up-z-a'}); + sortOptions.push({ + description: 'Persistent Sort A-Z', + event: () => { + if (fieldSortedAsc){ + this.setColumnSort(undefined); + this.highlightSortedColumn(); + } else { + this.sortDocs(this.columnKeys[index], false); + this.setColumnSort(this.columnKeys[index], false); + } + }, + icon: fieldSortedAsc ? 'lock' : 'lock-open'}); // prettier-ignore + sortOptions.push({ + description: 'Persistent Sort Z-A', + event: () => { + if (fieldSortedDesc){ + this.setColumnSort(undefined); + this.highlightSortedColumn(); + } else { + this.sortDocs(this.columnKeys[index], true); + this.setColumnSort(this.columnKeys[index], true); + } + }, + icon: fieldSortedDesc ? 'lock' : 'lock-open'}); // prettier-ignore + + cm.addItem({ + description: `Change field`, + event: () => this.openNewColumnMenu(index, false), icon: 'pencil-alt', }); - ContextMenu.Instance.addItem({ + cm.addItem({ description: 'Filter field', event: () => this.openFilterMenu(index), icon: 'filter', }); - ContextMenu.Instance.addItem({ + cm.addItem({ + description: 'Sort column', + addDivider: false, + noexpand: true, + subitems: sortOptions, + icon: 'sort' + }); + cm.addItem({ + description: 'Add column to left', + event: () => this.addColumn(index), + icon: 'plus', + }); + cm.addItem({ + description: 'Add column to right', + event: () => this.addColumn(index + 1), + icon: 'plus', + }); + cm.addItem({ description: 'Delete column', event: () => this.removeColumn(index), icon: 'trash', }); - ContextMenu.Instance.displayMenu(x, y, undefined, false); + cm.displayMenu(x, y, undefined, false); }; + //used in schemacolumnheader @action - updateKeySearch = (e: React.ChangeEvent<HTMLInputElement>) => { - this._menuValue = e.target.value; - this._menuKeys = this.documentKeys.filter(value => value.toLowerCase().includes(this._menuValue.toLowerCase())); + updateKeySearch = (val: string) => { + this._menuKeys = this.documentKeys.filter(value => value.toLowerCase().includes(val.toLowerCase())); }; getFieldFilters = (field: string) => StrListCast(this.Document._childFilters).filter(filter => filter.split(Doc.FilterSep)[0] === field); @@ -792,65 +1027,6 @@ export class CollectionSchemaView extends CollectionSubView() { this._filterSearchValue = e.target.value; }; - @computed get newFieldMenu() { - return ( - <div className="schema-new-key-options"> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.Number} - onChange={action(() => { - this._newFieldType = ColumnType.Number; - this._newFieldDefault = 0; - })} - /> - number - </div> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.Boolean} - onChange={action(() => { - this._newFieldType = ColumnType.Boolean; - this._newFieldDefault = false; - })} - /> - boolean - </div> - <div className="schema-key-type-option"> - <input - type="radio" - name="newFieldType" - checked={this._newFieldType === ColumnType.String} - onChange={action(() => { - this._newFieldType = ColumnType.String; - this._newFieldDefault = ''; - })} - /> - string - </div> - <div className="schema-key-default-val">value: {this.fieldDefaultInput}</div> - <div className="schema-key-warning">{this._newFieldWarning}</div> - <div - className="schema-column-menu-button" - onPointerDown={action(() => { - if (this.documentKeys.includes(this._menuValue)) { - this._newFieldWarning = 'Field already exists'; - } else if (this._menuValue.length === 0) { - this._newFieldWarning = 'Field cannot be an empty string'; - } else { - this.setKey(this._menuValue, this._newFieldDefault); - } - this._columnMenuIndex = undefined; - })}> - done - </div> - </div> - ); - } - onKeysPassiveWheel = (e: WheelEvent) => { // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) if (!this._oldKeysWheel?.scrollTop && e.deltaY <= 0) e.preventDefault(); @@ -861,14 +1037,6 @@ export class CollectionSchemaView extends CollectionSubView() { return ( <div className="schema-key-search"> <div - className="schema-column-menu-button" - onPointerDown={action(e => { - e.stopPropagation(); - this._makeNewField = true; - })}> - + new field - </div> - <div className="schema-key-list" ref={r => { this._oldKeysWheel?.removeEventListener('wheel', this.onKeysPassiveWheel); @@ -886,11 +1054,8 @@ export class CollectionSchemaView extends CollectionSubView() { <p> <span className="schema-search-result-key"> <b>{key}</b> - {this.fieldInfos.get(key)!.fieldType ? ':' : ''} - </span> - <span className="schema-search-result-type" style={{ color: this.fieldInfos.get(key)!.readOnly ? 'red' : 'inherit' }}> - {this.fieldInfos.get(key)!.fieldType} </span> + <span>: </span> <span className="schema-search-result-desc"> {this.fieldInfos.get(key)!.description}</span> </p> </div> @@ -903,17 +1068,8 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get renderColumnMenu() { const x = this._columnMenuIndex! === -1 ? 0 : this.displayColumnWidths.reduce((total, curr, index) => total + (index < this._columnMenuIndex! ? curr : 0), CollectionSchemaView._rowMenuWidth); return ( - <div className="schema-column-menu" style={{ left: x, minWidth: CollectionSchemaView._minColWidth }}> - <input className="schema-key-search-input" type="text" onKeyDown={this.onSearchKeyDown} onChange={this.updateKeySearch} onPointerDown={e => e.stopPropagation()} /> - {this._makeNewField ? this.newFieldMenu : this.keysDropdown} - </div> - ); - } - get renderKeysMenu() { - return ( - <div className="schema-column-menu" style={{ left: 0, minWidth: CollectionSchemaView._minColWidth }}> - <input className="schema-key-search-input" type="text" onKeyDown={this.onSearchKeyDown} onChange={this.updateKeySearch} onPointerDown={e => e.stopPropagation()} /> - {this._makeNewField ? this.newFieldMenu : this.keysDropdown} + <div className="schema-column-menu" style={{ left: x, maxWidth: `${Math.max(this._colEles[this._columnMenuIndex ?? 0].offsetWidth, 150)}px` }}> + {this.keysDropdown} </div> ); } @@ -939,7 +1095,7 @@ export class CollectionSchemaView extends CollectionSubView() { } return ( <div key={key} className="schema-filter-option"> - <input // + <input type="checkbox" onPointerDown={e => e.stopPropagation()} onClick={e => e.stopPropagation()} @@ -955,7 +1111,7 @@ export class CollectionSchemaView extends CollectionSubView() { @computed get renderFilterMenu() { const x = this.displayColumnWidths.reduce((total, curr, index) => total + (index < this._filterColumnIndex! ? curr : 0), CollectionSchemaView._rowMenuWidth); return ( - <div className="schema-filter-menu" style={{ left: x, minWidth: CollectionSchemaView._minColWidth }}> + <div className="schema-filter-menu" style={{ left: x, maxWidth: `${Math.max(this._colEles[this._columnMenuIndex ?? 0].offsetWidth, 150)}px`}}> <input className="schema-filter-input" type="text" value={this._filterSearchValue} onKeyDown={this.onFilterKeyDown} onChange={this.updateFilterSearch} onPointerDown={e => e.stopPropagation()} /> {this.renderFilterOptions} <div @@ -970,51 +1126,177 @@ export class CollectionSchemaView extends CollectionSubView() { ); } + @action setColDrag = (beingDragged: boolean) => { + this._colBeingDragged = beingDragged; + !beingDragged && this.removeDragHighlight(); + } + + @action updateMouseCoordinates = (e: React.PointerEvent<HTMLDivElement>) => { + const prevX = this._mouseCoordinates.x; + const prevY = this._mouseCoordinates.y; + this._mouseCoordinates = { x: e.clientX, y: e.clientY, prevX: prevX, prevY: prevY }; + } + @action onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { if (DragManager.docsBeingDragged.length) { - this._mouseCoordinates = { x: e.clientX, y: e.clientY }; + this.updateMouseCoordinates(e); } if (this._colBeingDragged) { + this.updateMouseCoordinates(e); const newIndex = this.findColDropIndex(e.clientX); - if (newIndex !== this._draggedColIndex) this.moveColumn(this._draggedColIndex, newIndex ?? this._draggedColIndex); - this._draggedColIndex = newIndex || this._draggedColIndex; - this.highlightDraggedColumn(newIndex ?? this._draggedColIndex); + const direction: number = this._mouseCoordinates.x > this._mouseCoordinates.prevX ? 1 : 0; + if (newIndex !== undefined && ((newIndex > this._draggedColIndex && direction === 1) || (newIndex < this._draggedColIndex && direction === 0))) { + this.moveColumn(this._draggedColIndex, newIndex ?? this._draggedColIndex); + this._draggedColIndex = newIndex !== undefined ? newIndex : this._draggedColIndex; + } + this.highlightSortedColumn(); //TODO: Make this more efficient + this.restoreCellHighlights(); + !(this.sortField && this._draggedColIndex === this.columnKeys.indexOf(this.sortField)) && this.highlightDraggedColumn(this._draggedColIndex); } }; - @computed get sortedDocs() { - const draggedDocs = this.isContentActive() ? DragManager.docsBeingDragged : []; - const field = StrCast(this.layoutDoc.sortField); - const desc = BoolCast(this.layoutDoc.sortDesc); // is this an ascending or descending sort - const staticDocs = this.childDocs.filter(d => !draggedDocs.includes(d)); - const docs = !field - ? staticDocs - : [...staticDocs].sort((docA, docB) => { - // this sorts the documents based on the selected field. returning -1 for a before b, 0 for a = b, 1 for a > b - const aStr = Field.toString(docA[field] as FieldType); - const bStr = Field.toString(docB[field] as FieldType); - let out = 0; - if (aStr < bStr) out = -1; - if (aStr > bStr) out = 1; - if (desc) out *= -1; - return out; - }); - - docs.splice(this.rowDropIndex, 0, ...draggedDocs); + /** + * Gets docs contained by collections within the schema. Currently defunct. + * @param doc + * @param displayed + * @returns + */ + // subCollectionDocs = (doc: Doc, displayed: boolean) => { + // const childDocs = DocListCast(doc[Doc.LayoutFieldKey(doc)]); + // let collections: Array<Doc> = []; + // if (displayed) collections = childDocs.filter(d => d.type === 'collection' && d._childrenSharedWithSchema); + // else collections = childDocs.filter(d => d.type === 'collection' && !d._childrenSharedWithSchema); + // let toReturn: Doc[] = [...childDocs]; + // collections.forEach(d => toReturn = toReturn.concat(this.subCollectionDocs(d, displayed))); + // return toReturn; + // } + + /** + * Applies any filters active on the schema to filter out docs that don't match. + */ + @computed get filteredDocs() { + const childDocFilters = this.childDocFilters(); + const childFiltersByRanges = this.childDocRangeFilters(); + const searchDocs = this.searchFilterDocs(); + + const docsforFilter: Doc[] = []; + this._containedDocs.forEach(d => { + // dragging facets + const dragged = this._props.childFilters?.().some(f => f.includes(ClientUtils.noDragDocsFilter)); + if (dragged && SnappingManager.CanEmbed && DragManager.docsBeingDragged.includes(d)) return; + let notFiltered = d.z || Doc.IsSystem(d) || DocUtils.FilterDocs([d], this.unrecursiveDocFilters(), childFiltersByRanges, this.Document).length > 0; + if (notFiltered) { + notFiltered = (!searchDocs.length || searchDocs.includes(d)) && DocUtils.FilterDocs([d], childDocFilters, childFiltersByRanges, this.Document).length > 0; + const fieldKey = Doc.LayoutFieldKey(d); + const isAnnotatableDoc = d[fieldKey] instanceof List && !(d[fieldKey] as List<Doc>)?.some(ele => !(ele instanceof Doc)); + const docChildDocs = d[isAnnotatableDoc ? fieldKey + '_annotations' : fieldKey]; + const sidebarDocs = isAnnotatableDoc && d[fieldKey + '_sidebar']; + if (docChildDocs !== undefined || sidebarDocs !== undefined) { + let subDocs = [...DocListCast(docChildDocs), ...DocListCast(sidebarDocs)]; + if (subDocs.length > 0) { + let newarray: Doc[] = []; + notFiltered = notFiltered || (!searchDocs.length && DocUtils.FilterDocs(subDocs, childDocFilters, childFiltersByRanges, d).length); + while (subDocs.length > 0 && !notFiltered) { + newarray = []; + // eslint-disable-next-line no-loop-func + subDocs.forEach(t => { + const docFieldKey = Doc.LayoutFieldKey(t); + const isSubDocAnnotatable = t[docFieldKey] instanceof List && !(t[docFieldKey] as List<Doc>)?.some(ele => !(ele instanceof Doc)); + notFiltered = + notFiltered || ((!searchDocs.length || searchDocs.includes(t)) && ((!childDocFilters.length && !childFiltersByRanges.length) || DocUtils.FilterDocs([t], childDocFilters, childFiltersByRanges, d).length)); + DocListCast(t[isSubDocAnnotatable ? docFieldKey + '_annotations' : docFieldKey]).forEach(newdoc => newarray.push(newdoc)); + isSubDocAnnotatable && DocListCast(t[docFieldKey + '_sidebar']).forEach(newdoc => newarray.push(newdoc)); + }); + subDocs = newarray; + } + } + } + } + notFiltered && docsforFilter.push(d); + }); + return docsforFilter; + } + + /** + * Returns all child docs of the schema and child docs of contained collections that satisfy applied filters. + */ + @computed get docs() { + //let docsFromChildren: Doc[] = []; + + // Functionality for adding child docs + //const displayedCollections = this.childDocs.filter(d => d.type === 'collection' && d._childrenSharedWithSchema); + // displayedCollections.forEach(d => { + // let docsNotAlreadyDisplayed = this.subCollectionDocs(d, true).filter(dc => !this._containedDocs.includes(dc)); + // docsFromChildren = docsFromChildren.concat(docsNotAlreadyDisplayed); + // }); + + return this.filteredDocs;; + } + + /** + * Sorts docs first alphabetically and then numerically. + * @param field the column being sorted + * @param desc whether the sort is ascending or descending + * @param persistent whether the sort is applied persistently or is one-shot + * @returns + */ + sortDocs = (field: string, desc: boolean, persistent?: boolean) => { + const numbers: Doc[] = []; + const strings: Doc[] = []; + + this.docs.forEach(doc => { + if (!isNaN(Number(Field.toString(doc[field] as FieldType)))) numbers.push(doc); + else strings.push(doc); + }); + + const sortedNums = numbers.sort((numOne, numTwo) => { + const numA = Number(Field.toString(numOne[field] as FieldType)); + const numB = Number(Field.toString(numTwo[field] as FieldType)); + return desc? numA - numB : numB - numA; + }); + + const collator = new Intl.Collator(undefined, {sensitivity: 'base'}); + let sortedStrings; + if (!desc) {sortedStrings = strings.slice().sort((docA, docB) => collator.compare(Field.toString(docA[field] as FieldType), Field.toString(docB[field] as FieldType))); + } else sortedStrings = strings.slice().sort((docB, docA) => collator.compare(Field.toString(docA[field] as FieldType), Field.toString(docB[field] as FieldType))); + + const sortedDocs = desc ? sortedNums.concat(sortedStrings) : sortedStrings.concat(sortedNums); + if (!persistent) this._containedDocs = sortedDocs; + return sortedDocs; + } + + /** + * Returns all docs minus those currently being dragged by the user. + */ + @computed get docsWithDrag() { + let docs = this.docs.slice(); + if (this.sortField){ + const field = StrCast(this.layoutDoc.sortField); + const desc = BoolCast(this.layoutDoc.sortDesc); // is this an ascending or descending sort + docs = this.sortDocs(field, desc, true); + } else { + const draggedDocs = this.isContentActive() ? DragManager.docsBeingDragged.filter(doc => !(doc.type === 'fonticonbox')) : []; + docs = docs.filter(d => !draggedDocs.includes(d)); + docs.splice(this.rowDropIndex, 0, ...draggedDocs); + } + return { docs }; } rowHeightFunc = () => (BoolCast(this.layoutDoc._schema_singleLine) ? CollectionSchemaView._rowSingleLineHeight : CollectionSchemaView._rowHeight); - sortedDocsFunc = () => this.sortedDocs; isContentActive = () => this._props.isSelected() || this._props.isContentActive(); screenToLocal = () => this.ScreenToLocalBoxXf().translate(-this.tableWidth, 0); previewWidthFunc = () => this.previewWidth; onPassiveWheel = (e: WheelEvent) => e.stopPropagation(); - _oldWheel: HTMLDivElement | null = null; + displayedDocsFunc = () => this.docsWithDrag.docs; + _oldWheel: any; render() { return ( - <div className="collectionSchemaView" ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} onDrop={this.onExternalDrop.bind(this)} onPointerMove={e => this.onPointerMove(e)}> + <div className="collectionSchemaView" ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} + onDrop={this.onExternalDrop.bind(this)} + onPointerMove={e => this.onPointerMove(e)} + onPointerDown={() => {this.closeNewColumnMenu(); this.setColDrag(false)}}> <div ref={this._menuTarget} style={{ background: 'red', top: 0, left: 0, position: 'absolute', zIndex: 10000 }} /> <div className="schema-table" @@ -1027,26 +1309,38 @@ export class CollectionSchemaView extends CollectionSubView() { }}> <div className="schema-header-row" style={{ height: this.rowHeightFunc() }}> <div className="row-menu" style={{ width: CollectionSchemaView._rowMenuWidth }}> - <Popup - placement="right" - background={SettingsManager.userBackgroundColor} - color={SettingsManager.userColor} - toggle={<FontAwesomeIcon onPointerDown={() => this.openColumnMenu(-1, true)} icon="plus" />} - trigger={PopupTrigger.CLICK} - type={Type.TERT} - isOpen={this._columnMenuIndex !== -1 ? false : undefined} - popup={this.renderKeysMenu} + <IconButton + tooltip="Add a new key" + icon={ <FontAwesomeIcon icon="plus" size='lg'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this.addColumn() + }, 'add key to schema') + ) + } /> </div> {this.columnKeys.map((key, index) => ( <SchemaColumnHeader // eslint-disable-next-line react/no-array-index-key + //cleanupField={this.cleanupComputedField} + ref={r => r && this._headerRefs.push(r)} + keysDropdown={(this.keysDropdown)} + schemaView={this} + columnWidth={() => CollectionSchemaView._minColWidth} //TODO: update + Document={this.Document} key={index} columnIndex={index} columnKeys={this.columnKeys} columnWidths={this.displayColumnWidths} - sortField={this.sortField} - sortDesc={this.sortDesc} setSort={this.setColumnSort} rowHeight={this.rowHeightFunc} removeColumn={this.removeColumn} @@ -1064,7 +1358,7 @@ export class CollectionSchemaView extends CollectionSubView() { // eslint-disable-next-line no-use-before-define <CollectionSchemaViewDocs schema={this} - childDocs={this.sortedDocsFunc} + childDocs={this.displayedDocsFunc} rowHeight={this.rowHeightFunc} setRef={(ref: HTMLDivElement | null) => { this._tableContentRef = ref; @@ -1187,7 +1481,7 @@ class CollectionSchemaViewDoc extends ObservableReactComponent<CollectionSchemaV interface CollectionSchemaViewDocsProps { schema: CollectionSchemaView; setRef: (ref: HTMLDivElement | null) => void; - childDocs: () => { docs: Doc[] }; + childDocs: () => Doc[]; rowHeight: () => number; } @@ -1196,7 +1490,7 @@ class CollectionSchemaViewDocs extends React.Component<CollectionSchemaViewDocsP render() { return ( <div className="schema-table-content" ref={this.props.setRef} style={{ height: `calc(100% - ${CollectionSchemaView._newNodeInputHeight + this.props.rowHeight()}px)` }}> - {this.props.childDocs().docs.map((doc: Doc, index: number) => ( + {this.props.childDocs().map((doc: Doc, index: number) => ( <div key={doc[Id]} className="schema-row-wrapper" style={{ height: this.props.rowHeight() }}> <CollectionSchemaViewDoc doc={doc} schema={this.props.schema} index={index} rowHeight={this.props.rowHeight} /> </div> @@ -1204,4 +1498,4 @@ class CollectionSchemaViewDocs extends React.Component<CollectionSchemaViewDocsP </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaCellField.tsx b/src/client/views/collections/collectionSchema/SchemaCellField.tsx new file mode 100644 index 000000000..84e7b62bf --- /dev/null +++ b/src/client/views/collections/collectionSchema/SchemaCellField.tsx @@ -0,0 +1,405 @@ +import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { ObservableReactComponent } from '../../ObservableReactComponent'; +import { observer } from 'mobx-react'; +import { OverlayView } from '../../OverlayView'; +import { DocumentIconContainer } from '../../nodes/DocumentIcon'; +import React, { FormEvent } from 'react'; +import { FieldView, FieldViewProps } from '../../nodes/FieldView'; +import { ObjectField } from '../../../../fields/ObjectField'; +import { Doc } from '../../../../fields/Doc'; +import { DocumentView } from '../../nodes/DocumentView'; +import DOMPurify from 'dompurify'; + +/** + * The SchemaCellField renders text in schema cells while the user is editing, and updates the + * contents of the field based on user input. It handles some cell-side logic for equations, such + * as how equations are broken up within the text. + * + * The current implementation parses innerHTML to create spans based on the text in the cell. + * A more robust/safer approach would directly add elements in the react structure, but this + * has been challenging to implement. + */ + +export interface SchemaCellFieldProps { + contents: any; + fieldContents?: FieldViewProps; + editing?: boolean; + oneLine?: boolean; + Document: Doc; + fieldKey: string; + refSelectModeInfo: { enabled: boolean; currEditing: SchemaCellField | undefined }; + highlightCells?: (text: string) => void; + GetValue(): string | undefined; + SetValue(value: string, shiftDown?: boolean, enterKey?: boolean): boolean; + getCells: (text: string) => HTMLDivElement[] | []; +} + +@observer +export class SchemaCellField extends ObservableReactComponent<SchemaCellFieldProps> { + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _inputref: HTMLDivElement | null = null; + private _unrenderedContent: string = ''; + _overlayDisposer?: () => void; + @observable _editing: boolean = false; + @observable _displayedContent = ''; + @observable _inCellSelectMode: boolean = false; + @observable _dependencyMessageShown: boolean = false; + + constructor(props: SchemaCellFieldProps) { + super(props); + makeObservable(this); + setTimeout(() => { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent); + }); //must be moved to end of batch or else other docs aren't loaded, so render as d-1 in function + } + + get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore + + get selfRefPattern() { + return `d${this.docIndex}.${this._props.fieldKey}`; + } + + @computed get lastCharBeforeCursor() { + const pos = this.cursorPosition; + const content = this._unrenderedContent; + const text = this._unrenderedContent.substring(0, pos ?? content.length); + for (let i = text.length - 1; i > 0; --i) { + if (text.charCodeAt(i) !== 160 && text.charCodeAt(i) !== 32) { + return text[i]; + } + } + return null; + } + + @computed get refSelectConditionMet() { + const char = this.lastCharBeforeCursor; + return char === '+' || char === '*' || char === '/' || char === '%' || char === '='; + } + + componentDidMount(): void { + this._unrenderedContent = this._props.GetValue() ?? ''; + this.setContent(this._unrenderedContent, true); + this._disposers.editing = reaction( + () => this._editing, + editing => { + if (editing) { + this.setupRefSelect(this.refSelectConditionMet); + setTimeout(() => { + if (this._inputref?.innerText.startsWith('=') || this._inputref?.innerText.startsWith(':=')) { + this._overlayDisposer?.(); + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + this._props.highlightCells?.(this._unrenderedContent); + this.setContent(this._unrenderedContent); + setTimeout(() => this.setCursorPosition(this._unrenderedContent.length)); + } + }); + } else { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + this._props.highlightCells?.(''); + this.setupRefSelect(false); + } + }, + { fireImmediately: true } + ); + this._disposers.fieldUpdate = reaction( + () => this._props.GetValue(), + fieldVal => { + console.log('Update: ' + this._props.Document.title, this._props.fieldKey, fieldVal); + this._unrenderedContent = fieldVal ?? ''; + this.finalizeEdit(false, false, false); + } + ); + } + + componentDidUpdate(prevProps: Readonly<SchemaCellFieldProps>) { + super.componentDidUpdate(prevProps); + if (this._editing && this._props.editing === false) { + this.finalizeEdit(false, true, false); + } else + runInAction(() => { + if (this._props.editing !== undefined) this._editing = this._props.editing; + }); + } + + _unmounted = false; + componentWillUnmount(): void { + this._unmounted = true; + console.log('Unmount: ' + this._props.Document.title, this._props.fieldKey); + this._overlayDisposer?.(); + Object.values(this._disposers).forEach(disposer => disposer?.()); + this.finalizeEdit(false, true, false); + } + + generateSpan = (text: string, cell: HTMLDivElement | undefined) => { + const selfRef = text === this.selfRefPattern; + return `<span style="text-decoration: ${selfRef ? 'underline' : 'none'}; text-decoration-color: red; color: ${selfRef ? 'gray' : cell?.style.borderTop.replace('2px solid', '')}">${text}</span>`; + }; + + makeSpans = (content: string) => { + let chunkedText = content; + + const pattern = /(this|d(\d+))\.(\w+)/g; + const matches: string[] = []; + let match: RegExpExecArray | null; + + const cells: Map<string, HTMLDivElement> = new Map(); + + while ((match = pattern.exec(content)) !== null) { + const cell = this._props.getCells(match[0]); + if (cell.length) { + matches.push(match[0]); + cells.set(match[0], cell[0]); + } + } + + matches.forEach((match: string) => { + chunkedText = chunkedText.replace(match, this.generateSpan(match, cells.get(match))); + }); + + return chunkedText; + }; + + /** + * Sets the rendered content of the cell to save user inputs. + * @param content the content to set + * @param restoreCursorPos whether the cursor should be set back to where it was rather than the 0th index; should usually be true + */ + @action + setContent = (content: string, restoreCursorPos?: boolean) => { + const pos = this.cursorPosition; + this._displayedContent = DOMPurify.sanitize(this.makeSpans(content)); + restoreCursorPos && setTimeout(() => this.setCursorPosition(pos)); + }; + + //Called from schemaview when a cell is selected to add a reference to the equation + /** + * Inserts text at the given index. + * @param text The text to append. + * @param atPos he index at which to insert the text. If empty, defaults to end. + */ + @action + insertText = (text: string, atPos?: boolean) => { + const content = this._unrenderedContent; + const cursorPos = this.cursorPosition; + const robustPos = cursorPos ?? content.length; + const newText = atPos ? content.slice(0, robustPos) + text + content.slice(cursorPos ?? content.length) : this._unrenderedContent.concat(text); + this.onChange(undefined, newText); + setTimeout(() => this.setCursorPosition(robustPos + text.length)); + }; + + @action + setIsFocused = (value: boolean) => { + const wasFocused = this._editing; + this._editing = value; + return wasFocused !== this._editing; + }; + + /** + * Gets the cursor's position index within the text being edited. + */ + get cursorPosition() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !this._inputref) return null; + + const range = selection.getRangeAt(0); + const adjRange = range.cloneRange(); + + adjRange.selectNodeContents(this._inputref); + adjRange.setEnd(range.startContainer, range.startOffset); + + return adjRange.toString().length; + } + + setCursorPosition = (position: number | null) => { + const selection = window.getSelection(); + if (!selection || position === null || !this._inputref) return; + + const range = document.createRange(); + range.setStart(this._inputref, 0); + range.collapse(true); + + let currentPos = 0; + const setRange = (nodes: NodeList) => { + for (let i = 0; i < nodes.length; ++i) { + const node = nodes[i]; + + if (node.nodeType === Node.TEXT_NODE) { + if (!node.textContent) return; + const nextPos = currentPos + node.textContent.length; + if (position <= nextPos) { + range.setStart(node, position - currentPos); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } + currentPos = nextPos; + } else if (node.nodeType === Node.ELEMENT_NODE && setRange(node.childNodes)) return true; + } + return false; + }; + + setRange(this._inputref.childNodes); + }; + + //This function checks if a visual update (eg. coloring a cell reference) should be made. It's meant to + //save on processing upkeep vs. constantly rerendering, but I think the savings are minimal for now + shouldUpdate = (prevVal: string, currVal: string) => { + if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true; + }; + + onChange = (e: FormEvent<HTMLDivElement> | undefined, newText?: string) => { + const prevVal = this._unrenderedContent; + const targVal = newText ?? e!.currentTarget.innerText; // TODO: bang + if (!(targVal.startsWith(':=') || targVal.startsWith('='))) { + this._overlayDisposer?.(); + this._overlayDisposer = undefined; + } else if (!this._overlayDisposer) { + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + } + this._unrenderedContent = targVal; + this._props.highlightCells?.(targVal); + if (this.shouldUpdate(prevVal, targVal)) this.setContent(targVal, true); + this.setupRefSelect(this.refSelectConditionMet); + }; + + setupRefSelect = (enabled: boolean) => { + const properties = this._props.refSelectModeInfo; + properties.enabled = enabled; + properties.currEditing = enabled ? this : undefined; + }; + + @action + onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.nativeEvent.defaultPrevented) return; // hack .. DashFieldView grabs native events, but react ignores stoppedPropagation and preventDefault, so we need to check it here + + switch (e.key) { + case 'Tab': + e.stopPropagation(); + this.finalizeEdit(e.shiftKey, false, false); + break; + case 'Backspace': + e.stopPropagation(); + break; + case 'Enter': + e.stopPropagation(); + if (!e.ctrlKey) { + this.finalizeEdit(e.shiftKey, false, true); + } + break; + case 'Escape': + e.stopPropagation(); + this._editing = false; + break; + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': // prettier-ignore + e.stopPropagation(); + setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0); + break; + case ' ': + e.stopPropagation(); + let cursorPos = 0; + if (this.cursorPosition !== null) cursorPos = this.cursorPosition + 1; + setTimeout(() => { + this.setContent(this._unrenderedContent); + setTimeout(() => this.setCursorPosition(cursorPos)); + }, 0); + break; + case 'u': // for some reason 'u' otherwise exits the editor + e.stopPropagation(); + break; + case 'Shift': + case 'Alt': + case 'Meta': + case 'Control': + case ':': // prettier-ignore + break; + // eslint-disable-next-line no-fallthrough + default: + break; + } + }; + + @action + onClick = (e?: React.MouseEvent) => { + if (this._props.editing !== false) { + e?.nativeEvent.stopPropagation(); + this._editing = true; + } + }; + + @action + finalizeEdit = (shiftDown: boolean, lostFocus: boolean, enterKey: boolean) => { + if (this._unmounted) { + return; + } + if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) { + if (this._dependencyMessageShown) { + this._dependencyMessageShown = false; + } else alert(`Circular dependency detected. Please update the field at ${this.selfRefPattern}.`); + this._dependencyMessageShown = true; + return; + } + + this.setContent(this._unrenderedContent); + + if (!this._props.SetValue(this._unrenderedContent, shiftDown, enterKey) && !lostFocus) { + setTimeout(action(() => (this._editing = true))); + } + this._editing = false; + }; + + staticDisplay = () => { + return <span className="editableView-static">{this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : ''}</span>; + }; + + renderEditor = () => { + return ( + <div + contentEditable + className="schemaField-editing" + ref={r => { + this._inputref = r; + }} + style={{ cursor: 'text', outline: 'none', overflow: 'auto', minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20 }} + onBlur={() => (this._props.refSelectModeInfo.enabled ? setTimeout(() => this.setIsFocused(true), 1000) : this.finalizeEdit(false, true, false))} + autoFocus + onInput={this.onChange} + onKeyDown={this.onKeyDown} + onPointerDown={e => { + e.stopPropagation(); + setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0); + }} //timeout callback ensures that refSelectMode is properly set + onClick={e => e.stopPropagation} + onPointerUp={e => e.stopPropagation} + onPointerMove={e => { + e.stopPropagation(); + e.preventDefault(); + }} + dangerouslySetInnerHTML={{ __html: this._displayedContent }}></div> + ); + }; + + render() { + const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n'); + if (this._editing && gval !== undefined) { + return <div className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`}>{this.renderEditor()}</div>; + } else + return this._props.contents instanceof ObjectField ? null : ( + <div + className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`} + style={{ + minHeight: '10px', + whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line', + width: '100%', + }} + onClick={this.onClick}> + {this.staticDisplay()} + </div> + ); + } +} diff --git a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx index e0ed8d01e..c5cdac8af 100644 --- a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx +++ b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx @@ -1,78 +1,248 @@ /* eslint-disable react/no-unused-prop-types */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action } from 'mobx'; +import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { setupMoveUpEvents } from '../../../../ClientUtils'; +import { returnEmptyFilter, returnFalse, returnZero, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Colors } from '../../global/globalEnums'; import './CollectionSchemaView.scss'; +import { EditableView } from '../../EditableView'; +import { ObservableReactComponent } from '../../ObservableReactComponent'; +import { DefaultStyleProvider, returnEmptyDocViewList } from '../../StyleProvider'; +import { FieldViewProps } from '../../nodes/FieldView'; +import { Doc, returnEmptyDoclist } from '../../../../fields/Doc'; +import { dropActionType } from '../../../util/DropActionTypes'; +import { Transform } from '../../../util/Transform'; +import { SchemaTableCell } from './SchemaTableCell'; +import { DocCast } from '../../../../fields/Types'; +import { computedFn } from 'mobx-utils'; +import { CollectionSchemaView } from './CollectionSchemaView'; +import { undoable } from '../../../util/UndoManager'; +import { IconButton, Size } from 'browndash-components'; + +export enum SchemaFieldType { + Header, Cell +} export interface SchemaColumnHeaderProps { + Document: Doc; + autoFocus?: boolean; columnKeys: string[]; columnWidths: number[]; columnIndex: number; - sortField: string; - sortDesc: boolean; + schemaView: CollectionSchemaView; + keysDropdown: React.JSX.Element; + //cleanupField: (s: string) => string; isContentActive: (outsideReaction?: boolean | undefined) => boolean | undefined; setSort: (field: string | undefined, desc?: boolean) => void; removeColumn: (index: number) => void; rowHeight: () => number; - resizeColumn: (e: React.PointerEvent, index: number) => void; + resizeColumn: (e: React.PointerEvent, index: number, rightSide: boolean) => void; dragColumn: (e: PointerEvent, index: number) => boolean; openContextMenu: (x: number, y: number, index: number) => void; setColRef: (index: number, ref: HTMLDivElement) => void; + rootSelected?: () => boolean; + columnWidth: () => number; + finishEdit?: () => void; // notify container that edit is over (eg. to hide view in DashFieldView) + //transform: () => Transform; } @observer -export class SchemaColumnHeader extends React.Component<SchemaColumnHeaderProps> { - get fieldKey() { - return this.props.columnKeys[this.props.columnIndex]; +export class SchemaColumnHeader extends ObservableReactComponent<SchemaColumnHeaderProps> { + + private _inputRef: EditableView | null = null; + @observable _altTitle: string | undefined = undefined; + @observable _showMenuIcon: boolean = false; + + @computed get fieldKey() { + return this._props.columnKeys[this._props.columnIndex]; } - @action - sortClicked = (e: React.PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (this.props.sortField === this.fieldKey && this.props.sortDesc) { - this.props.setSort(undefined); - } else if (this.props.sortField === this.fieldKey) { - this.props.setSort(this.fieldKey, true); - } else { - this.props.setSort(this.fieldKey, false); - } + constructor(props: SchemaColumnHeaderProps){ + super(props); + makeObservable(this); + } + + getFinfo = computedFn((fieldKey: string) => this._props.schemaView?.fieldInfos.get(fieldKey)); + setColumnValues = (field: string, defaultValue: string) => {this._props.schemaView?.setKey(field, defaultValue, this._props.columnIndex);} + @action updateAlt = (newAlt: string) => {this._altTitle = newAlt}; + updateKeyDropdown = (value: string) => {this._props.schemaView.updateKeySearch(value)}; + openKeyDropdown = () => {!this._props.schemaView._colBeingDragged && this._props.schemaView.openNewColumnMenu(this._props.columnIndex, false)}; + toggleEditing = (editing: boolean) => { + this._inputRef?.setIsEditing(editing); + this._inputRef?.setIsFocused(editing); }; @action - onPointerDown = (e: React.PointerEvent) => { - this.props.isContentActive(true) && setupMoveUpEvents(this, e, moveEv => this.props.dragColumn(moveEv, this.props.columnIndex), emptyFunction, emptyFunction); + setupDrag = (e: React.PointerEvent) => { + this._props.isContentActive(true) && setupMoveUpEvents(this, e, moveEv => this._props.dragColumn(moveEv, this._props.columnIndex), emptyFunction, emptyFunction); }; + renderProps = (props: SchemaColumnHeaderProps) => { + const { columnKeys, columnWidth, Document } = props; + const fieldKey = columnKeys[props.columnIndex]; + const color = 'black'; + const fieldProps: FieldViewProps = { + childFilters: returnEmptyFilter, + childFiltersByRanges: returnEmptyFilter, + docViewPath: returnEmptyDocViewList, + searchFilterDocs: returnEmptyDoclist, + styleProvider: DefaultStyleProvider, + isSelected: returnFalse, + setHeight: returnFalse, + select: emptyFunction, + dragAction: dropActionType.move, + renderDepth: 1, + noSidebar: true, + isContentActive: returnFalse, + whenChildContentsActiveChanged: emptyFunction, + ScreenToLocalTransform: Transform.Identity, + focus: emptyFunction, + addDocTab: SchemaTableCell.addFieldDoc, + pinToPres: returnZero, + Document: DocCast(Document.rootDocument, Document), + fieldKey: fieldKey, + PanelWidth: columnWidth, + PanelHeight: props.rowHeight, + rootSelected: props.rootSelected, + }; + const readOnly = this.getFinfo(fieldKey)?.readOnly ?? false; + const cursor = !readOnly ? 'text' : 'default'; + const pointerEvents: 'all' | 'none' = 'all'; + return { color, fieldProps, cursor, pointerEvents }; + } + + @computed get editableView() { + const { color, fieldProps, pointerEvents } = this.renderProps(this._props); + + return <div className='schema-column-edit-wrapper' onPointerUp={() => { + SchemaColumnHeader.isDefaultField(this.fieldKey) && this.openKeyDropdown(); + this._props.schemaView.deselectAllCells(); + }} + style={{ + color, + width: '100%', + pointerEvents, + }}> + <EditableView + ref={r => {this._inputRef = r; this._props.autoFocus && r?.setIsFocused(true)}} + oneLine={true} + allowCRs={false} + contents={''} + onClick={this.openKeyDropdown} + fieldContents={fieldProps} + editing={undefined} + placeholder={'Add key'} + updateAlt={this.updateAlt} // alternate title to display + updateSearch={this.updateKeyDropdown} + schemaFieldType={SchemaFieldType.Header} + GetValue={() => { + if (SchemaColumnHeader.isDefaultField(this.fieldKey)) return ''; + else if (this._altTitle) return this._altTitle; + else return this.fieldKey; + }} + SetValue={undoable((value: string, shiftKey?: boolean, enterKey?: boolean) => { + if (enterKey) { // if shift & enter, set value of each cell in column + this.setColumnValues(value, ''); + this._altTitle = undefined; + this._props.finishEdit?.(); + return true; + } + this._props.finishEdit?.(); + return true; + }, 'edit column header')} + /> + </div> + } + + public static isDefaultField = (key: string) => { + const defaultPattern = /EmptyColumnKey/; + const isDefault: boolean = (defaultPattern.exec(key) != null); + return isDefault; + } + + get headerButton(){ + const toRender = SchemaColumnHeader.isDefaultField(this.fieldKey) ? + (<IconButton + icon={ <FontAwesomeIcon icon="trash" size='sm'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this._props.schemaView.removeColumn(this._props.columnIndex); + }, 'open column menu') + ) + } + />) + : (<IconButton + icon={ <FontAwesomeIcon icon="caret-down" size='lg'/>} + size={Size.XSMALL} + color={'black'} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this._props.openContextMenu(e.clientX, e.clientY, this._props.columnIndex) + }, 'open column menu') + ) + } + />) + + return toRender; + } + + @action handlePointerEnter = () => this._showMenuIcon = true; + @action handlePointerLeave = () => this._showMenuIcon = false; + + @computed get displayButton() {return this._showMenuIcon;} + render() { return ( - <div - className="schema-column-header" - style={{ - width: this.props.columnWidths[this.props.columnIndex], - }} - onPointerDown={this.onPointerDown} - ref={col => { - if (col) { - this.props.setColRef(this.props.columnIndex, col); + <div + className="schema-column-header" + style={{ + width: this._props.columnWidths[this._props.columnIndex], + }} + onPointerEnter={() => {this.handlePointerEnter()}} + onPointerLeave={() => {this.handlePointerLeave()}} + onPointerDown={e => { + this.setupDrag(e); + setupMoveUpEvents( + this, + e, + () => {return this._inputRef?.setIsEditing(false) ?? false}, + emptyFunction, + emptyFunction, + ); + } } - }}> - <div className="schema-column-resizer left" onPointerDown={e => this.props.resizeColumn(e, this.props.columnIndex)} /> - <div className="schema-column-title">{this.fieldKey}</div> - - <div className="schema-header-menu"> - <div className="schema-header-button" onPointerDown={e => this.props.openContextMenu(e.clientX, e.clientY, this.props.columnIndex)}> - <FontAwesomeIcon icon="ellipsis-h" /> - </div> - <div className="schema-sort-button" onPointerDown={this.sortClicked} style={this.props.sortField === this.fieldKey ? { backgroundColor: Colors.MEDIUM_BLUE } : {}}> - <FontAwesomeIcon icon="caret-right" style={this.props.sortField === this.fieldKey ? { transform: `rotate(${this.props.sortDesc ? '270deg' : '90deg'})` } : {}} /> - </div> + ref={col => { + if (col) { + this._props.setColRef(this._props.columnIndex, col); + } + }}> + <div className="schema-column-resizer left" onPointerDown={e => this._props.resizeColumn(e, this._props.columnIndex, false)} /> + + <div className="schema-header-text">{this.editableView}</div> + + <div className="schema-header-menu"> + <div className="schema-header-button" style={{opacity: this.displayButton ? '1.0' : '0.0'}}> + {this.headerButton} + </div> + </div> + + <div className="schema-column-resizer right" onPointerDown={e => this._props.resizeColumn(e, this._props.columnIndex, true)} /> </div> - </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx index a7e0e916b..a8a4ef2c2 100644 --- a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx +++ b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx @@ -1,10 +1,8 @@ import { IconButton, Size } from 'browndash-components'; -import { computed, makeObservable } from 'mobx'; +import { computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; -import { CgClose, CgLock, CgLockUnlock } from 'react-icons/cg'; -import { FaExternalLinkAlt } from 'react-icons/fa'; import { returnFalse, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { Doc } from '../../../../fields/Doc'; @@ -12,12 +10,20 @@ import { BoolCast } from '../../../../fields/Types'; import { Transform } from '../../../util/Transform'; import { undoable } from '../../../util/UndoManager'; import { ViewBoxBaseComponent } from '../../DocComponent'; -import { Colors } from '../../global/globalEnums'; import { FieldView, FieldViewProps } from '../../nodes/FieldView'; import { OpenWhere } from '../../nodes/OpenWhere'; import { CollectionSchemaView } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; import { SchemaTableCell } from './SchemaTableCell'; +import { ContextMenu } from '../../ContextMenu'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +/** + * The SchemaRowBox renders a doc as a row of cells, with each cell representing + * one field value of the doc. It mostly handles communication from the SchemaView + * to each SchemaCell, passing down necessary functions are props. + */ interface SchemaRowBoxProps extends FieldViewProps { rowIndex: number; @@ -28,6 +34,7 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { return FieldView.LayoutString(SchemaRowBox, fieldKey).replace('fieldKey', `rowIndex={${rowIndex}} fieldKey`); } private _ref: HTMLDivElement | null = null; + @observable _childrenAddedToSchema: boolean = false; constructor(props: SchemaRowBoxProps) { super(props); @@ -44,29 +51,77 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { return this.schemaView.Document; } - @computed get rowIndex() { - return this.schemaView?.rowIndex(this.Document) ?? -1; - } - componentDidMount(): void { this._props.setContentViewBox?.(this); } + openContextMenu = (x: number, y: number) => { + ContextMenu.Instance.clearItems(); + ContextMenu.Instance.addItem({ + description: this.Document._lockedSchemaEditing ? 'Unlock field editing' : 'Lock field editing', + event: () => this.Document._lockedSchemaEditing = !this.Document._lockedSchemaEditing, + icon: this.Document._lockedSchemaEditing ? 'lock-open' : 'lock', + }); + ContextMenu.Instance.addItem({ + description: 'Open preview', + event: () => this._props.addDocTab(this.Document, OpenWhere.addRight), + icon: 'magnifying-glass', + }); + ContextMenu.Instance.addItem({ + description: `Close doc`, + event: () => this.schemaView.removeDoc(this.Document), + icon: 'minus', + }); + // Defunct option to add child docs of collections to the main schema + // const childDocs = DocListCast(this.Document[Doc.LayoutFieldKey(this.Document)]) + // if (this.Document.type === 'collection' && childDocs.length) { + // ContextMenu.Instance.addItem({ + // description: this.Document._childrenSharedWithSchema ? 'Remove children from schema' : 'Add children to schema', + // event: () => { + // this.Document._childrenSharedWithSchema = !this.Document._childrenSharedWithSchema; + // }, + // icon: this.Document._childrenSharedWithSchema ? 'minus' : 'plus', + // }); + // } + ContextMenu.Instance.displayMenu(x, y, undefined, false); + } + + @computed get menuBackgroundColor(){ + if (this.Document._lockedSchemaEditing) {return '#F5F5F5'} + return '' + } + + @computed get menuInfos() { + const infos: Array<IconProp> = []; + if (this.Document._lockedSchemaEditing) infos.push('lock'); + if (this.Document._childrenSharedWithSchema) infos.push('star'); + return infos; + } + + isolatedSelection = (doc: Doc) => {return this.schemaView?.selectionOverlap(doc)}; setCursorIndex = (mouseY: number) => this.schemaView?.setRelCursorIndex(mouseY); selectedCol = () => this.schemaView._selectedCol; getFinfo = computedFn((fieldKey: string) => this.schemaView?.fieldInfos.get(fieldKey)); selectCell = (doc: Doc, col: number, shift: boolean, ctrl: boolean) => this.schemaView?.selectCell(doc, col, shift, ctrl); deselectCell = () => this.schemaView?.deselectAllCells(); selectedCells = () => this.schemaView?._selectedDocs; - setColumnValues = (field: string, value: string) => this.schemaView?.setColumnValues(field, value) ?? false; - setSelectedColumnValues = (field: string, value: string) => this.schemaView?.setSelectedColumnValues(field, value) ?? false; + setColumnValues = (field: any, value: any) => this.schemaView?.setCellValues(field, value) ?? false; columnWidth = computedFn((index: number) => () => this.schemaView?.displayColumnWidths[index] ?? CollectionSchemaView._minColWidth); + computeRowIndex = () => this.schemaView?.rowIndex(this.Document); + highlightCells = (text: string) => this.schemaView?.highlightCells(text); + selectReference = (doc: Doc, col: number) => {this.schemaView.selectReference(doc, col)} + eqHighlightFunc = (text: string) => { + const info = this.schemaView.findCellRefs(text); + const cells: HTMLDivElement[] = []; + info.forEach(info => {cells.push(this.schemaView.getCellElement(info[0], info[1]))}) + return cells; + }; render() { return ( <div className="schema-row" onPointerDown={e => this.setCursorIndex(e.clientY)} - style={{ height: this._props.PanelHeight(), backgroundColor: this._props.isSelected() ? Colors.LIGHT_BLUE : undefined }} + style={{ height: this._props.PanelHeight()}} ref={(row: HTMLDivElement | null) => { row && this.schemaView?.addRowRef?.(this.Document, row); this._ref = row; @@ -76,11 +131,13 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { style={{ width: CollectionSchemaView._rowMenuWidth, pointerEvents: !this._props.isContentActive() ? 'none' : undefined, + backgroundColor: this.menuBackgroundColor }}> <IconButton - tooltip="close" - icon={<CgClose size="16px" />} + tooltip="Open actions menu" + icon={ <FontAwesomeIcon icon="caret-right" size='lg'/>} size={Size.XSMALL} + color={'black'} onPointerDown={e => setupMoveUpEvents( this, @@ -89,50 +146,25 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { emptyFunction, undoable(clickEv => { clickEv.stopPropagation(); - this._props.removeDocument?.(this.Document); - }, 'Delete Row') - ) - } - /> - <IconButton - tooltip="whether document interactions are enabled" - icon={this.Document._lockedPosition ? <CgLockUnlock size="12px" /> : <CgLock size="12px" />} - size={Size.XSMALL} - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - Doc.toggleLockedPosition(this.Document); - }, 'toggle document lock') - ) - } - /> - <IconButton - tooltip="open preview" - icon={<FaExternalLinkAlt />} - size={Size.XSMALL} - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - this._props.addDocTab(this.Document, OpenWhere.addRight); - }, 'Open schema Doc preview') + this.openContextMenu(e.clientX, e.clientY) + }, 'open actions menu') ) } /> + <div className="row-menu-infos"> + {this.menuInfos.map(icn => <FontAwesomeIcon className="row-infos-icon" icon={icn} size='2xs' />)} + </div> </div> <div className="row-cells"> {this.schemaView?.columnKeys?.map((key, index) => ( <SchemaTableCell + selectReference={this.selectReference} + refSelectModeInfo={this.schemaView._referenceSelectMode} + eqHighlightFunc={this.eqHighlightFunc} + highlightCells={this.highlightCells} + isolatedSelection={this.isolatedSelection} key={key} + rowSelected={this._props.isSelected} Document={this.Document} col={index} fieldKey={key} @@ -146,7 +178,6 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { selectedCells={this.selectedCells} selectedCol={this.selectedCol} setColumnValues={this.setColumnValues} - setSelectedColumnValues={this.setSelectedColumnValues} oneLine={BoolCast(this.schemaDoc?._singleLine)} menuTarget={this.schemaView.MenuTarget} transform={() => { @@ -161,4 +192,4 @@ export class SchemaRowBox extends ViewBoxBaseComponent<SchemaRowBoxProps>() { </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx index 22506cac1..c05382ce0 100644 --- a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx @@ -12,7 +12,7 @@ import Select from 'react-select'; import { ClientUtils, StopEvent, returnEmptyFilter, returnFalse, returnZero } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { DateField } from '../../../../fields/DateField'; -import { Doc, DocListCast, Field, returnEmptyDoclist } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, IdToDoc, returnEmptyDoclist } from '../../../../fields/Doc'; import { RichTextField } from '../../../../fields/RichTextField'; import { ColumnType } from '../../../../fields/SchemaHeaderField'; import { BoolCast, Cast, DateCast, DocCast, FieldValue, StrCast, toList } from '../../../../fields/Types'; @@ -31,6 +31,14 @@ import { FieldViewProps } from '../../nodes/FieldView'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { FInfotoColType } from './CollectionSchemaView'; import './CollectionSchemaView.scss'; +import { SchemaColumnHeader } from './SchemaColumnHeader'; +import { SchemaCellField } from './SchemaCellField'; + +/** + * SchemaTableCells make up the majority of the visual representation of the SchemaView. + * They are rendered for each cell in the SchemaView, and each represents one field value + * of a doc. Editing the content of the cell changes the corresponding doc's field value. + */ export interface SchemaTableCellProps { Document: Doc; @@ -47,7 +55,6 @@ export interface SchemaTableCellProps { isRowActive: () => boolean | undefined; getFinfo: (fieldKey: string) => FInfo | undefined; setColumnValues: (field: string, value: string) => boolean; - setSelectedColumnValues: (field: string, value: string) => boolean; oneLine?: boolean; // whether all input should fit on one line vs allowing textare multiline inputs allowCRs?: boolean; // allow carriage returns in text input (othewrise CR ends the edit) finishEdit?: () => void; // notify container that edit is over (eg. to hide view in DashFieldView) @@ -56,23 +63,44 @@ export interface SchemaTableCellProps { transform: () => Transform; autoFocus?: boolean; // whether to set focus on creation, othwerise wait for a click rootSelected?: () => boolean; + rowSelected: () => boolean; + isolatedSelection: (doc: Doc) => [boolean, boolean]; + highlightCells: (text: string) => void; + eqHighlightFunc: (text: string) => HTMLDivElement[] | []; + refSelectModeInfo: {enabled: boolean, currEditing: SchemaCellField | undefined}; + selectReference: (doc: Doc, col: number) => void; } function selectedCell(props: SchemaTableCellProps) { return ( props.isRowActive() && - props.selectedCol() === props.col && // + props.selectedCol() === props.col && props.selectedCells()?.filter(d => d === props.Document)?.length ); } @observer export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellProps> { + + // private _fieldRef: SchemaCellField | null = null; + private _submittedValue: string = ''; + constructor(props: SchemaTableCellProps) { super(props); makeObservable(this); } + get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore + + get isDefault(){return SchemaColumnHeader.isDefaultField(this._props.fieldKey);} // prettier-ignore + + get lockedInteraction(){return (this.isDefault || this._props.Document._lockedSchemaEditing);} // prettier-ignore + + get backgroundColor(){ + if (this.lockedInteraction) {return '#F5F5F5'} + return '' + } + static addFieldDoc = (docs: Doc | Doc[] /* , where: OpenWhere */) => { DocumentView.FocusOrOpen(toList(docs)[0]); return true; @@ -82,15 +110,12 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro let protoCount = 0; let doc: Doc | undefined = Document; while (doc) { - if (Object.keys(doc).includes(fieldKey.replace(/^_/, ''))) { - break; - } + if (Object.keys(doc).includes(fieldKey.replace(/^_/, ''))) break; protoCount++; doc = DocCast(doc.proto); } - const parenCount = Math.max(0, protoCount - 1); const color = protoCount === 0 || (fieldKey.startsWith('_') && Document[fieldKey] === undefined) ? 'black' : 'blue'; // color of text in cells - const textDecoration = color !== 'black' && parenCount ? 'underline' : ''; + const textDecoration = ''; const fieldProps: FieldViewProps = { childFilters: returnEmptyFilter, childFiltersByRanges: returnEmptyFilter, @@ -121,33 +146,66 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro return { color, textDecoration, fieldProps, cursor, pointerEvents }; } + adjustSelfReference = (field: string) => { + const modField = field.replace(/\bthis.\b/g, `d${this.docIndex}.`); + return modField; + } + + // parses a field from the "idToDoc(####)" format to DocumentId (d#) format for readability + cleanupField = (field: string) => { + let modField = field.slice(); + let eqSymbol: string = ''; + if (modField.startsWith('=')) {modField = modField.substring(1); eqSymbol += '=';} + if (modField.startsWith(':=')) {modField = modField.substring(2); eqSymbol += ':=';} + + const idPattern = /idToDoc\((.*?)\)/g; + let matches; + const results = new Array<[id: string, func: string]>(); + while ((matches = idPattern.exec(field)) !== null) {results.push([matches[0], matches[1].replace(/"/g, '')]); } + results.forEach((idFuncPair) => {modField = modField.replace(idFuncPair[0], 'd' + (DocumentView.getDocViewIndex(IdToDoc(idFuncPair[1]))).toString());}) + + if (modField.endsWith(';')) modField = modField.substring(0, modField.length - 1); + + const inQuotes = (field: string) => {return ((field.startsWith('`') && field.endsWith('`')) || (field.startsWith("'") && field.endsWith("'")) || (field.startsWith('"') && field.endsWith('"')))} + if (!inQuotes(this._submittedValue) && inQuotes(modField)) modField = modField.substring(1, modField.length - 1); + + return eqSymbol + modField; + } + @computed get defaultCellContent() { const { color, textDecoration, fieldProps, pointerEvents } = SchemaTableCell.renderProps(this._props); return ( <div className="schemacell-edit-wrapper" + // onContextMenu={} style={{ color, textDecoration, width: '100%', - pointerEvents, + pointerEvents: this.lockedInteraction ? 'none' : pointerEvents, }}> - <EditableView + <SchemaCellField + fieldKey={this._props.fieldKey} + refSelectModeInfo={this._props.refSelectModeInfo} + Document={this._props.Document} + highlightCells={(text: string) => this._props.highlightCells(this.adjustSelfReference(text))} + getCells={(text: string) => this._props.eqHighlightFunc(this.adjustSelfReference(text))} ref={r => selectedCell(this._props) && this._props.autoFocus && r?.setIsFocused(true)} oneLine={this._props.oneLine} - allowCRs={this._props.allowCRs} - contents={''} + contents={undefined} fieldContents={fieldProps} editing={selectedCell(this._props) ? undefined : false} - GetValue={() => Field.toKeyValueString(fieldProps.Document, this._props.fieldKey, SnappingManager.MetaKey)} + GetValue={() => this.cleanupField(Field.toKeyValueString(fieldProps.Document, this._props.fieldKey, SnappingManager.MetaKey))} SetValue={undoable((value: string, shiftDown?: boolean, enterKey?: boolean) => { if (shiftDown && enterKey) { this._props.setColumnValues(this._props.fieldKey.replace(/^_/, ''), value); this._props.finishEdit?.(); return true; } - const ret = Doc.SetField(fieldProps.Document, this._props.fieldKey.replace(/^_/, ''), value, Doc.IsDataProto(fieldProps.Document) ? true : undefined); + const hasNoLayout = Doc.IsDataProto(fieldProps.Document) ? true : undefined; // the "delegate" is a a data document so never write to it's proto + const ret = Doc.SetField(fieldProps.Document, this._props.fieldKey.replace(/^_/, ''), value, hasNoLayout); + this._submittedValue = value; this._props.finishEdit?.(); return ret; }, 'edit schema cell')} @@ -183,23 +241,48 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro } } + @computed get borderColor() { + const sides: Array<string | undefined> = []; + sides[0] = selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // left + sides[1] = selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // right + sides[2] = (!this._props.isolatedSelection(this._props.Document)[0] && selectedCell(this._props)) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // top + sides[3] = (!this._props.isolatedSelection(this._props.Document)[1] && selectedCell(this._props)) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined; // bottom + return sides; + } + render() { return ( <div className="schema-table-cell" onContextMenu={e => StopEvent(e)} onPointerDown={action(e => { + if (this.lockedInteraction) { e.stopPropagation(); e.preventDefault(); return; } + + if (this._props.refSelectModeInfo.enabled && !selectedCell(this._props)){ + e.stopPropagation(); + e.preventDefault(); + this._props.selectReference(this._props.Document, this._props.col); + return; + } + const shift: boolean = e.shiftKey; const ctrl: boolean = e.ctrlKey; - if (this._props.isRowActive?.() !== false) { + if (this._props.isRowActive?.()) { if (selectedCell(this._props) && ctrl) { this._props.selectCell(this._props.Document, this._props.col, shift, ctrl); e.stopPropagation(); } else !selectedCell(this._props) && this._props.selectCell(this._props.Document, this._props.col, shift, ctrl); } })} - style={{ padding: this._props.padding, maxWidth: this._props.maxWidth?.(), width: this._props.columnWidth() || undefined, border: selectedCell(this._props) ? `solid 2px ${Colors.MEDIUM_BLUE}` : undefined }}> - {this.content} + style={{ padding: this._props.padding, + maxWidth: this._props.maxWidth?.(), + width: this._props.columnWidth() || undefined, + borderLeft: this.borderColor[0], + borderRight: this.borderColor[1], + borderTop: this.borderColor[2], + borderBottom: this.borderColor[3], + backgroundColor: this.backgroundColor}}> + {this.isDefault ? '' : this.content} </div> ); } @@ -441,4 +524,4 @@ export class SchemaEnumerationCell extends ObservableReactComponent<SchemaTableC </div> ); } -} +}
\ No newline at end of file |
