import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import ReactTable, { CellInfo, Column, ComponentPropsGetterR, Resize, SortingRule } from 'react-table'; import { DateField } from '../../../../fields/DateField'; import { AclPrivate, AclReadonly, DataSym, Doc, DocListCast, Field, Opt } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { listSpec } from '../../../../fields/Schema'; import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; import { ComputedField } from '../../../../fields/ScriptField'; import { Cast, FieldValue, NumCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { GetEffectiveAcl } from '../../../../fields/util'; import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue } from '../../../../Utils'; import { Docs, DocumentOptions, DocUtils } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; import { CompileScript, Transformer, ts } from '../../../util/Scripting'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; import '../../../views/DocumentDecorations.scss'; import { ContextMenu } from '../../ContextMenu'; import { COLLECTION_BORDER_WIDTH, SCHEMA_DIVIDER_WIDTH } from '../../global/globalCssVariables.scss'; import { DocumentView, OpenWhere } from '../../nodes/DocumentView'; import { PinProps } from '../../nodes/trails'; import { DefaultStyleProvider } from '../../StyleProvider'; import { CollectionView } from '../CollectionView'; import { CellProps, CollectionSchemaButtons, CollectionSchemaCell, CollectionSchemaCheckboxCell, CollectionSchemaDateCell, CollectionSchemaDocCell, CollectionSchemaImageCell, CollectionSchemaListCell, CollectionSchemaNumberCell, CollectionSchemaStringCell, } from './CollectionSchemaCells'; import { CollectionSchemaAddColumnHeader, KeysDropdown } from './CollectionSchemaHeaders'; import { MovableColumn } from './CollectionSchemaMovableColumn'; import { MovableRow } from './CollectionSchemaMovableRow'; import './CollectionSchemaView.scss'; enum ColumnType { Any, Number, String, Boolean, Doc, Image, List, Date, } // this map should be used for keys that should have a const type of value const columnTypes: Map = new Map([ ['title', ColumnType.String], ['x', ColumnType.Number], ['y', ColumnType.Number], ['_width', ColumnType.Number], ['_height', ColumnType.Number], ['_nativeWidth', ColumnType.Number], ['_nativeHeight', ColumnType.Number], ['isPrototype', ColumnType.Boolean], ['_curPage', ColumnType.Number], ['_currentTimecode', ColumnType.Number], ['zIndex', ColumnType.Number], ]); export interface SchemaTableProps { Document: Doc; // child doc dataDoc?: Doc; PanelHeight: () => number; PanelWidth: () => number; childDocs?: Doc[]; CollectionView: Opt; ContainingCollectionView: Opt; ContainingCollectionDoc: Opt; fieldKey: string; renderDepth: number; deleteDocument?: (document: Doc | Doc[]) => boolean; addDocument?: (document: Doc | Doc[]) => boolean; moveDocument?: (document: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; ScreenToLocalTransform: () => Transform; active: (outsideReaction: boolean | undefined) => boolean | undefined; onDrop: (e: React.DragEvent, options: DocumentOptions, completed?: (() => void) | undefined) => void; addDocTab: (document: Doc, where: OpenWhere) => boolean; pinToPres: (document: Doc, pinProps: PinProps) => void; isSelected: (outsideReaction?: boolean) => boolean; isFocused: (document: Doc, outsideReaction: boolean) => boolean; setFocused: (document: Doc) => void; setPreviewDoc: (document: Opt) => void; columns: SchemaHeaderField[]; documentKeys: any[]; headerIsEditing: boolean; openHeader: (column: any, screenx: number, screeny: number) => void; onClick: (e: React.MouseEvent) => void; onPointerDown: (e: React.PointerEvent) => void; onResizedChange: (newResized: Resize[], event: any) => void; setColumns: (columns: SchemaHeaderField[]) => void; reorderColumns: (toMove: SchemaHeaderField, relativeTo: SchemaHeaderField, before: boolean, columnsValues: SchemaHeaderField[]) => void; changeColumns: (oldKey: string, newKey: string, addNew: boolean) => void; setHeaderIsEditing: (isEditing: boolean) => void; changeColumnSort: (columnField: SchemaHeaderField, descending: boolean | undefined) => void; } @observer export class SchemaTable extends React.Component { @observable _cellIsEditing: boolean = false; @observable _focusedCell: { row: number; col: number } = { row: 0, col: 0 }; @observable _openCollections: Set = new Set(); @observable _showDoc: Doc | undefined; @observable _showDataDoc: any = ''; @observable _showDocPos: number[] = []; @observable _showTitleDropdown: boolean = false; @computed get previewWidth() { return () => NumCast(this.props.Document.schemaPreviewWidth); } @computed get previewHeight() { return () => this.props.PanelHeight() - 2 * this.borderWidth; } @computed get tableWidth() { return this.props.PanelWidth() - 2 * this.borderWidth - Number(SCHEMA_DIVIDER_WIDTH) - this.previewWidth(); } @computed get childDocs() { if (this.props.childDocs) return this.props.childDocs; const doc = this.props.dataDoc ? this.props.dataDoc : this.props.Document; return DocListCast(doc[this.props.fieldKey]); } set childDocs(docs: Doc[]) { const doc = this.props.dataDoc ? this.props.dataDoc : this.props.Document; doc[this.props.fieldKey] = new List(docs); } @computed get textWrappedRows() { return Cast(this.props.Document.textwrappedSchemaRows, listSpec('string'), []); } set textWrappedRows(textWrappedRows: string[]) { this.props.Document.textwrappedSchemaRows = new List(textWrappedRows); } @computed get resized(): { id: string; value: number }[] { return this.props.columns.reduce((resized, shf) => { shf.width > -1 && resized.push({ id: shf.heading, value: shf.width }); return resized; }, [] as { id: string; value: number }[]); } @computed get sorted(): SortingRule[] { return this.props.columns.reduce((sorted, shf) => { shf.desc !== undefined && sorted.push({ id: shf.heading, desc: shf.desc }); return sorted; }, [] as SortingRule[]); } @action changeSorting = (col: any) => { this.props.changeColumnSort(col, col.desc === true ? false : col.desc === false ? undefined : true); }; @action changeTitleMode = () => (this._showTitleDropdown = !this._showTitleDropdown); @computed get borderWidth() { return Number(COLLECTION_BORDER_WIDTH); } @computed get tableColumns(): Column[] { const possibleKeys = this.props.documentKeys.filter(key => this.props.columns.findIndex(existingKey => existingKey.heading.toUpperCase() === key.toUpperCase()) === -1); const columns: Column[] = []; const tableIsFocused = this.props.isFocused(this.props.Document, false); const focusedRow = this._focusedCell.row; const focusedCol = this._focusedCell.col; const isEditable = !this.props.headerIsEditing; columns.push({ expander: true, Header: '', width: 58, Expander: rowInfo => { return rowInfo.original.type !== DocumentType.COL ? null : (
this._openCollections[rowInfo.isExpanded ? 'delete' : 'add'](rowInfo.viewIndex))}>
); }, }); columns.push( ...this.props.columns.map(col => { const icon: IconProp = this.getColumnType(col) === ColumnType.Number ? 'hashtag' : this.getColumnType(col) === ColumnType.String ? 'font' : this.getColumnType(col) === ColumnType.Boolean ? 'check-square' : this.getColumnType(col) === ColumnType.Doc ? 'file' : this.getColumnType(col) === ColumnType.Image ? 'image' : this.getColumnType(col) === ColumnType.List ? 'list-ul' : this.getColumnType(col) === ColumnType.Date ? 'calendar' : 'align-justify'; const keysDropdown = ( c.heading)} canAddNew={true} addNew={false} onSelect={this.props.changeColumns} setIsEditing={this.props.setHeaderIsEditing} docs={this.props.childDocs} Document={this.props.Document} dataDoc={this.props.dataDoc} fieldKey={this.props.fieldKey} ContainingCollectionDoc={this.props.ContainingCollectionDoc} ContainingCollectionView={this.props.ContainingCollectionView} active={this.props.active} openHeader={this.props.openHeader} icon={icon} col={col} // try commenting this out width={'100%'} /> ); const sortIcon = col.desc === undefined ? 'caret-right' : col.desc === true ? 'caret-down' : 'caret-up'; const header = (
{keysDropdown}
this.changeSorting(col)} style={{ width: 21, padding: 1, display: 'inline', zIndex: 1, background: 'inherit', cursor: 'pointer' }}>
{/* {this.props.Document._chromeHidden || this.props.addDocument == returnFalse ? undefined :
+ new
} */}
); return { Header: , accessor: (doc: Doc) => (doc ? Field.toString(doc[col.heading] as Field) : 0), id: col.heading, Cell: (rowProps: CellInfo) => { const rowIndex = rowProps.index; const columnIndex = this.props.columns.map(c => c.heading).indexOf(rowProps.column.id!); const isFocused = focusedRow === rowIndex && focusedCol === columnIndex && tableIsFocused; const props: CellProps = { row: rowIndex, col: columnIndex, rowProps: rowProps, isFocused: isFocused, changeFocusedCellByIndex: this.changeFocusedCellByIndex, CollectionView: this.props.CollectionView, ContainingCollection: this.props.ContainingCollectionView, Document: this.props.Document, fieldKey: this.props.fieldKey, renderDepth: this.props.renderDepth, addDocTab: this.props.addDocTab, pinToPres: this.props.pinToPres, moveDocument: this.props.moveDocument, setIsEditing: this.setCellIsEditing, isEditable: isEditable, setPreviewDoc: this.props.setPreviewDoc, setComputed: this.setComputed, getField: this.getField, showDoc: this.showDoc, }; switch (this.getColumnType(col, rowProps.original, rowProps.column.id)) { case ColumnType.Number: return ; case ColumnType.String: return ; case ColumnType.Boolean: return ; case ColumnType.Doc: return ; case ColumnType.Image: return ; case ColumnType.List: return ; case ColumnType.Date: return ; default: return ; } }, minWidth: 200, }; }) ); columns.push({ Header: , accessor: (doc: Doc) => 0, id: 'add', Cell: (rowProps: CellInfo) => { const rowIndex = rowProps.index; const columnIndex = this.props.columns.map(c => c.heading).indexOf(rowProps.column.id!); const isFocused = focusedRow === rowIndex && focusedCol === columnIndex && tableIsFocused; return ( ); }, width: 28, resizable: false, }); return columns; } constructor(props: SchemaTableProps) { super(props); if (this.props.Document._schemaHeaders === undefined) { this.props.Document._schemaHeaders = new List([ new SchemaHeaderField('title', '#f1efeb'), new SchemaHeaderField('author', '#f1efeb'), new SchemaHeaderField('*lastModified', '#f1efeb', ColumnType.Date), new SchemaHeaderField('text', '#f1efeb', ColumnType.String), new SchemaHeaderField('type', '#f1efeb'), new SchemaHeaderField('context', '#f1efeb', ColumnType.Doc), ]); } } componentDidMount() { document.addEventListener('keydown', this.onKeyDown); } componentWillUnmount() { document.removeEventListener('keydown', this.onKeyDown); } tableAddDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => { const tableDoc = this.props.Document[DataSym]; const effectiveAcl = GetEffectiveAcl(tableDoc); if (effectiveAcl !== AclPrivate && effectiveAcl !== AclReadonly) { doc.context = this.props.Document; tableDoc[this.props.fieldKey + '-lastModified'] = new DateField(new Date(Date.now())); return Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, relativeTo, before); } return false; }; private getTrProps: ComponentPropsGetterR = (state, rowInfo) => { return !rowInfo ? {} : { ScreenToLocalTransform: this.props.ScreenToLocalTransform, addDoc: this.tableAddDoc, removeDoc: this.props.deleteDocument, rowInfo, rowFocused: !this.props.headerIsEditing && rowInfo.index === this._focusedCell.row && this.props.isFocused(this.props.Document, true), textWrapRow: this.toggleTextWrapRow, rowWrapped: this.textWrappedRows.findIndex(id => rowInfo.original[Id] === id) > -1, dropAction: StrCast(this.props.Document.childDropAction), addDocTab: this.props.addDocTab, }; }; private getTdProps: ComponentPropsGetterR = (state, rowInfo, column, instance) => { if (!rowInfo || column) return {}; const row = rowInfo.index; //@ts-ignore const col = this.columns.map(c => c.heading).indexOf(column!.id); const isFocused = this._focusedCell.row === row && this._focusedCell.col === col && this.props.isFocused(this.props.Document, true); // TODO: editing border doesn't work :( return { style: { border: !this.props.headerIsEditing && isFocused ? '2px solid rgb(255, 160, 160)' : '1px solid #f1efeb' }, }; }; @action setCellIsEditing = (isEditing: boolean) => (this._cellIsEditing = isEditing); @action onKeyDown = (e: KeyboardEvent): void => { if (!this._cellIsEditing && !this.props.headerIsEditing && this.props.isFocused(this.props.Document, true)) { // && this.props.isSelected(true)) { const direction = e.key === 'Tab' ? 'tab' : e.which === 39 ? 'right' : e.which === 37 ? 'left' : e.which === 38 ? 'up' : e.which === 40 ? 'down' : ''; this._focusedCell = this.changeFocusedCellByDirection(direction, this._focusedCell.row, this._focusedCell.col); if (direction) { const pdoc = FieldValue(this.childDocs[this._focusedCell.row]); pdoc && this.props.setPreviewDoc(pdoc); e.stopPropagation(); } } else if (e.keyCode === 27) { this.props.setPreviewDoc(undefined); e.stopPropagation(); // stopPropagation for left/right arrows } }; changeFocusedCellByDirection = (direction: string, curRow: number, curCol: number) => { switch (direction) { case 'tab': return { row: curRow + 1 === this.childDocs.length ? 0 : curRow + 1, col: curCol + 1 === this.props.columns.length ? 0 : curCol + 1 }; case 'right': return { row: curRow, col: curCol + 1 === this.props.columns.length ? curCol : curCol + 1 }; case 'left': return { row: curRow, col: curCol === 0 ? curCol : curCol - 1 }; case 'up': return { row: curRow === 0 ? curRow : curRow - 1, col: curCol }; case 'down': return { row: curRow + 1 === this.childDocs.length ? curRow : curRow + 1, col: curCol }; } return this._focusedCell; }; @action changeFocusedCellByIndex = (row: number, col: number): void => { if (this._focusedCell.row !== row || this._focusedCell.col !== col) { this._focusedCell = { row: row, col: col }; } this.props.setFocused(this.props.Document); }; @undoBatch createRow = action(() => { this.props.addDocument?.(Docs.Create.TextDocument('', { title: '', _width: 100, _height: 30 })); this._focusedCell = { row: this.childDocs.length, col: this._focusedCell.col }; }); @undoBatch @action createColumn = () => { const newFieldName = (index: number) => `New field${index ? ` (${index})` : ''}`; for (let index = 0; index < 100; index++) { if (this.props.columns.findIndex(col => col.heading === newFieldName(index)) === -1) { this.props.columns.push(new SchemaHeaderField(newFieldName(index), '#f1efeb')); break; } } }; @action getColumnType = (column: SchemaHeaderField, doc?: Doc, field?: string): ColumnType => { if (doc && field && column.type === ColumnType.Any) { const val = doc[CollectionSchemaCell.resolvedFieldKey(field, doc)]; if (val instanceof ImageField) return ColumnType.Image; if (val instanceof Doc) return ColumnType.Doc; if (val instanceof DateField) return ColumnType.Date; if (val instanceof List) return ColumnType.List; } if (column.type && column.type !== 0) { return column.type; } if (columnTypes.get(column.heading)) { return (column.type = columnTypes.get(column.heading)!); } return (column.type = ColumnType.Any); }; @undoBatch @action toggleTextwrap = async () => { const textwrappedRows = Cast(this.props.Document.textwrappedSchemaRows, listSpec('string'), []); if (textwrappedRows.length) { this.props.Document.textwrappedSchemaRows = new List([]); } else { const docs = DocListCast(this.props.Document[this.props.fieldKey]); const allRows = docs instanceof Doc ? [docs[Id]] : docs.map(doc => doc[Id]); this.props.Document.textwrappedSchemaRows = new List(allRows); } }; @action toggleTextWrapRow = (doc: Doc): void => { const textWrapped = this.textWrappedRows; const index = textWrapped.findIndex(id => doc[Id] === id); index > -1 ? textWrapped.splice(index, 1) : textWrapped.push(doc[Id]); this.textWrappedRows = textWrapped; }; @computed get reactTable() { const children = this.childDocs; const hasCollectionChild = children.reduce((found, doc) => found || doc.type === DocumentType.COL, false); const expanded: { [name: string]: any } = {}; Array.from(this._openCollections.keys()).map(col => (expanded[col.toString()] = true)); const rerender = [...this.textWrappedRows]; // TODO: get component to rerender on text wrap change without needign to console.log :(((( return ( row.original.type !== DocumentType.COL ? null : (
) } /> ); } onContextMenu = (e: React.MouseEvent): void => { ContextMenu.Instance.addItem({ description: 'Toggle text wrapping', event: this.toggleTextwrap, icon: 'table' }); }; getField = (row: number, col?: number) => { const docs = this.childDocs; row = row % docs.length; while (row < 0) row += docs.length; const columns = this.props.columns; const doc = docs[row]; if (col === undefined) { return doc; } if (col >= 0 && col < columns.length) { const column = this.props.columns[col].heading; return doc[column]; } return undefined; }; createTransformer = (row: number, col: number): Transformer => { const self = this; const captures: { [name: string]: Field } = {}; const transformer: ts.TransformerFactory = context => { return root => { function visit(node: ts.Node) { node = ts.visitEachChild(node, visit, context); if (ts.isIdentifier(node)) { const isntPropAccess = !ts.isPropertyAccessExpression(node.parent) || node.parent.expression === node; const isntPropAssign = !ts.isPropertyAssignment(node.parent) || node.parent.name !== node; if (isntPropAccess && isntPropAssign) { if (node.text === '$r') { return ts.createNumericLiteral(row.toString()); } else if (node.text === '$c') { return ts.createNumericLiteral(col.toString()); } else if (node.text === '$') { if (ts.isCallExpression(node.parent)) { // captures.doc = self.props.Document; // captures.key = self.props.fieldKey; } } } } return node; } return ts.visitNode(root, visit); }; }; // const getVars = () => { // return { capturedVariables: captures }; // }; return { transformer /*getVars*/ }; }; setComputed = (script: string, doc: Doc, field: string, row: number, col: number): boolean => { script = `const $ = (row:number, col?:number) => { const rval = (doc as any)[key][row + ${row}]; return col === undefined ? rval : rval[(doc as any)._schemaHeaders[col + ${col}].heading]; } return ${script}`; const compiled = CompileScript(script, { params: { this: Doc.name }, capturedVariables: { doc: this.props.Document, key: this.props.fieldKey }, typecheck: false, transformer: this.createTransformer(row, col) }); if (compiled.compiled) { doc[field] = new ComputedField(compiled); return true; } return false; }; @action showDoc = (doc: Doc | undefined, dataDoc?: Doc, screenX?: number, screenY?: number) => { this._showDoc = doc; if (dataDoc && screenX && screenY) { this._showDocPos = this.props.ScreenToLocalTransform().transformPoint(screenX, screenY); } }; onOpenClick = () => { this._showDoc && this.props.addDocTab(this._showDoc, OpenWhere.addRight); }; getPreviewTransform = (): Transform => { return this.props.ScreenToLocalTransform().translate(-this.borderWidth - 4 - this.tableWidth, -this.borderWidth); }; render() { const preview = ''; return (
this.props.active(true) && e.stopPropagation()} onDrop={e => this.props.onDrop(e, {})} onContextMenu={this.onContextMenu}> {this.reactTable} {this.props.Document._chromeHidden || this.props.addDocument === returnFalse ? undefined : (
+ new
)} {!this._showDoc ? null : (
150} PanelHeight={() => 150} ScreenToLocalTransform={this.getPreviewTransform} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} ContainingCollectionDoc={this.props.CollectionView?.props.Document} ContainingCollectionView={this.props.CollectionView} moveDocument={this.props.moveDocument} whenChildContentsActiveChanged={emptyFunction} addDocTab={this.props.addDocTab} pinToPres={this.props.pinToPres} bringToFront={returnFalse}>
)}
); } }