From 1654a2681f3f7ed6fac002be22ac6cb94b35bd88 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 2 Oct 2020 11:10:00 -0400 Subject: [PATCH] (core) move client code to core Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627 --- app/client/app.css | 174 ++ app/client/app.js | 33 + app/client/components/AceEditor.css | 18 + app/client/components/AceEditor.js | 402 +++++ app/client/components/ActionLog.css | 121 ++ app/client/components/ActionLog.ts | 445 +++++ app/client/components/Base.js | 94 ++ app/client/components/BaseView.js | 639 ++++++++ app/client/components/ChartView.css | 6 + app/client/components/ChartView.ts | 508 ++++++ app/client/components/ClientScope.ts | 55 + app/client/components/Clipboard.css | 13 + app/client/components/Clipboard.js | 248 +++ app/client/components/CodeEditorPanel.css | 19 + app/client/components/CodeEditorPanel.js | 56 + app/client/components/ColumnFilters.css | 141 ++ app/client/components/ColumnTransform.ts | 183 +++ app/client/components/Comm.ts | 524 ++++++ app/client/components/Confirm.ts | 30 + app/client/components/CopySelection.js | 42 + app/client/components/Cursor.ts | 133 ++ app/client/components/CustomView.css | 9 + app/client/components/CustomView.ts | 300 ++++ app/client/components/DetailView.css | 270 ++++ app/client/components/DetailView.js | 394 +++++ app/client/components/DocComm.ts | 204 +++ app/client/components/DocConfigTab.js | 34 + app/client/components/DocList.css | 302 ++++ app/client/components/DocList.js | 331 ++++ app/client/components/EmbedForm.css | 39 + app/client/components/EmbedForm.js | 205 +++ app/client/components/FieldConfigTab.css | 9 + app/client/components/FieldConfigTab.js | 169 ++ app/client/components/FormulaTransform.ts | 54 + app/client/components/GridView.css | 237 +++ app/client/components/GridView.js | 1261 +++++++++++++++ app/client/components/GristDoc.css | 69 + app/client/components/GristDoc.ts | 693 ++++++++ app/client/components/GristWSConnection.ts | 379 +++++ app/client/components/Importer.ts | 509 ++++++ app/client/components/Layout.css | 89 + app/client/components/Layout.js | 472 ++++++ app/client/components/LayoutEditor.css | 97 ++ app/client/components/LayoutEditor.js | 738 +++++++++ app/client/components/LayoutPreview.css | 7 + app/client/components/LayoutPreview.js | 40 + app/client/components/LinkingState.js | 122 ++ app/client/components/Login.css | 81 + app/client/components/Login.js | 129 ++ app/client/components/ModalDialog.js | 125 ++ app/client/components/ParseOptions.ts | 118 ++ app/client/components/Preferences.css | 43 + app/client/components/ProfileForm.js | 160 ++ app/client/components/REPLTab.css | 106 ++ app/client/components/REPLTab.js | 318 ++++ app/client/components/RecordLayout.css | 46 + app/client/components/RecordLayout.js | 353 ++++ app/client/components/RecordLayoutEditor.js | 150 ++ app/client/components/RefSelect.js | 226 +++ app/client/components/SearchBar.css | 73 + app/client/components/SearchBar.ts | 375 +++++ app/client/components/Selector.js | 278 ++++ app/client/components/SummaryConfig.js | 129 ++ app/client/components/TypeConversion.ts | 196 +++ app/client/components/TypeTransform.ts | 140 ++ app/client/components/UndoStack.ts | 148 ++ app/client/components/ValidationPanel.css | 29 + app/client/components/ValidationPanel.js | 97 ++ app/client/components/ViewConfigTab.css | 38 + app/client/components/ViewConfigTab.js | 759 +++++++++ app/client/components/ViewLayout.css | 234 +++ app/client/components/ViewLayout.ts | 270 ++++ app/client/components/ViewLinker.css | 114 ++ app/client/components/ViewLinker.js | 334 ++++ app/client/components/ViewPane.ts | 6 + app/client/components/commandList.js | 404 +++++ app/client/components/commands.css | 53 + app/client/components/commands.js | 330 ++++ app/client/components/duplicatePage.ts | 179 ++ app/client/components/viewCommon.css | 180 +++ app/client/components/viewCommon.js | 69 + app/client/declarations.d.ts | 352 ++++ app/client/exposeModulesForTests.js | 13 + app/client/index.ts | 34 - app/client/lib/ACIndex.ts | 250 +++ app/client/lib/CustomSectionElement.ts | 47 + app/client/lib/Delay.ts | 91 ++ app/client/lib/DocPluginManager.ts | 66 + app/client/lib/ImportSourceElement.ts | 35 + app/client/lib/Mousetrap.js | 67 + app/client/lib/ObservableMap.js | 155 ++ app/client/lib/ObservableSet.js | 74 + app/client/lib/SafeBrowser.ts | 331 ++++ app/client/lib/SafeBrowserProcess.css | 12 + app/client/lib/autocomplete.ts | 239 +++ app/client/lib/browserGlobals.js | 54 + app/client/lib/browserInfo.ts | 13 + app/client/lib/chartUtil.ts | 20 + app/client/lib/copyToClipboard.ts | 38 + app/client/lib/dispose.d.ts | 16 + app/client/lib/dispose.js | 369 +++++ app/client/lib/dom.js | 453 ++++++ app/client/lib/domAsync.ts | 25 + app/client/lib/download.js | 31 + app/client/lib/fromKoSave.ts | 32 + app/client/lib/guessTimezone.ts | 11 + app/client/lib/helpScout.ts | 209 +++ app/client/lib/imports.d.ts | 20 + app/client/lib/imports.js | 16 + app/client/lib/koArray.d.ts | 31 + app/client/lib/koArray.js | 457 ++++++ app/client/lib/koArrayWrap.ts | 42 + app/client/lib/koDom.js | 426 +++++ app/client/lib/koDomScrolly.css | 3 + app/client/lib/koDomScrolly.js | 649 ++++++++ app/client/lib/koForm.css | 724 +++++++++ app/client/lib/koForm.js | 1163 +++++++++++++ app/client/lib/koSession.js | 90 ++ app/client/lib/koUtil.js | 217 +++ app/client/lib/listEntry.ts | 212 +++ app/client/lib/loadScript.js | 19 + app/client/lib/localStorageObs.ts | 19 + app/client/lib/multiselect.css | 37 + app/client/lib/multiselect.js | 89 + app/client/lib/sessionObs.ts | 53 + app/client/lib/sortUtil.ts | 92 ++ app/client/lib/tableUtil.js | 217 +++ app/client/lib/testState.ts | 11 + app/client/lib/uploads.ts | 172 ++ app/client/models/AppModel.ts | 162 ++ app/client/models/BaseRowModel.js | 136 ++ app/client/models/BillingModel.ts | 256 +++ app/client/models/ClientColumnGetters.ts | 28 + app/client/models/ColumnACIndexes.ts | 82 + app/client/models/ColumnFilter.ts | 109 ++ app/client/models/ConnectState.ts | 41 + app/client/models/DataRowModel.ts | 107 ++ app/client/models/DataTableModel.js | 319 ++++ app/client/models/DataTableModelWithDiff.ts | 196 +++ app/client/models/DocData.ts | 185 +++ app/client/models/DocListModel.js | 118 ++ app/client/models/DocModel.ts | 217 +++ app/client/models/DocPageModel.ts | 338 ++++ app/client/models/HomeModel.ts | 333 ++++ app/client/models/MetaRowModel.js | 83 + app/client/models/MetaTableModel.js | 241 +++ app/client/models/NotifyModel.ts | 369 +++++ app/client/models/QuerySet.ts | 359 +++++ app/client/models/SearchModel.ts | 338 ++++ app/client/models/SectionFilter.ts | 92 ++ app/client/models/TableData.ts | 141 ++ app/client/models/TableModel.js | 114 ++ app/client/models/TreeModel.ts | 261 +++ app/client/models/UserManagerModel.ts | 316 ++++ app/client/models/UserPrefs.ts | 41 + app/client/models/WorkspaceInfo.ts | 55 + .../models/entities/ACLMembershipRec.ts | 14 + app/client/models/entities/ACLPrincipalRec.ts | 29 + app/client/models/entities/ACLResourceRec.ts | 7 + app/client/models/entities/ColumnRec.ts | 92 ++ app/client/models/entities/DocInfoRec.ts | 19 + app/client/models/entities/PageRec.ts | 11 + app/client/models/entities/REPLRec.ts | 8 + app/client/models/entities/TabBarRec.ts | 11 + app/client/models/entities/TableRec.ts | 77 + app/client/models/entities/TableViewRec.ts | 13 + app/client/models/entities/ValidationRec.ts | 8 + app/client/models/entities/ViewFieldRec.ts | 204 +++ app/client/models/entities/ViewRec.ts | 53 + app/client/models/entities/ViewSectionRec.ts | 275 ++++ app/client/models/errors.ts | 149 ++ app/client/models/gristConfigCache.ts | 62 + app/client/models/modelUtil.js | 320 ++++ app/client/models/rowset.ts | 645 ++++++++ app/client/models/rowuid.js | 48 + app/client/ui/AccessRules.ts | 191 +++ app/client/ui/AccountWidget.ts | 224 +++ app/client/ui/AddNewButton.ts | 75 + app/client/ui/ApiKey.ts | 123 ++ app/client/ui/App.css | 48 + app/client/ui/App.ts | 244 +++ app/client/ui/AppHeader.ts | 60 + app/client/ui/AppUI.ts | 151 ++ app/client/ui/BillingForm.ts | 459 ++++++ app/client/ui/BillingPage.ts | 783 +++++++++ app/client/ui/BillingPageCss.ts | 271 ++++ app/client/ui/BillingPlanManagers.ts | 178 ++ app/client/ui/ColumnFilterMenu.ts | 288 ++++ app/client/ui/CustomThemes.ts | 59 + app/client/ui/DocHistory.ts | 125 ++ app/client/ui/DocMenu.ts | 431 +++++ app/client/ui/DocMenuCss.ts | 239 +++ app/client/ui/Document.css | 32 + app/client/ui/DocumentSettings.ts | 76 + app/client/ui/ExampleCard.ts | 173 ++ app/client/ui/ExampleInfo.ts | 62 + app/client/ui/FieldMenus.ts | 17 + app/client/ui/FileDialog.ts | 83 + app/client/ui/GridOptions.ts | 60 + app/client/ui/GridViewMenus.ts | 239 +++ app/client/ui/HomeImports.ts | 112 ++ app/client/ui/HomeIntro.ts | 204 +++ app/client/ui/HomeLeftPane.ts | 237 +++ app/client/ui/LeftPanelCommon.ts | 148 ++ app/client/ui/MakeCopyMenu.ts | 295 ++++ app/client/ui/MultiSelector.ts | 157 ++ app/client/ui/NotifyUI.ts | 320 ++++ app/client/ui/PageWidgetPicker.ts | 541 +++++++ app/client/ui/Pages.ts | 82 + app/client/ui/PinnedDocs.ts | 235 +++ app/client/ui/ProfileDialog.ts | 178 ++ app/client/ui/RightPanel.ts | 686 ++++++++ app/client/ui/ShareMenu.ts | 288 ++++ app/client/ui/Tools.ts | 92 ++ app/client/ui/TopBar.ts | 127 ++ app/client/ui/TopBarCss.ts | 34 + app/client/ui/TreeViewComponent.ts | 658 ++++++++ app/client/ui/TreeViewComponentCss.ts | 119 ++ app/client/ui/UserImage.ts | 127 ++ app/client/ui/UserManager.ts | 637 ++++++++ app/client/ui/ViewLayoutMenu.ts | 25 + app/client/ui/ViewSectionMenu.ts | 268 +++ app/client/ui/VisibleFieldsConfig.ts | 462 ++++++ app/client/ui/WelcomePage.ts | 206 +++ app/client/ui/buttons.ts | 95 ++ app/client/ui/errorPages.ts | 160 ++ app/client/ui/modals.ts | 71 + app/client/ui/selectBy.ts | 224 +++ app/client/ui/shadowScroll.ts | 48 + app/client/ui/tooltips.ts | 80 + app/client/ui/transientInput.ts | 50 + app/client/ui/widgetTypes.ts | 28 + app/client/ui2018/breadcrumbs.ts | 119 ++ app/client/ui2018/buttonSelect.ts | 264 +++ app/client/ui2018/buttons.ts | 127 ++ app/client/ui2018/checkbox.ts | 145 ++ app/client/ui2018/draggableList.ts | 15 + app/client/ui2018/editableLabel.ts | 149 ++ app/client/ui2018/links.ts | 18 + app/client/ui2018/loaders.ts | 22 + app/client/ui2018/menus.ts | 360 +++++ app/client/ui2018/modals.ts | 292 ++++ app/client/ui2018/pages.ts | 198 +++ app/client/ui2018/search.ts | 145 ++ app/client/widgets/AbstractWidget.js | 72 + app/client/widgets/BaseEditor.js | 51 + app/client/widgets/CheckBox.css | 58 + app/client/widgets/CheckBox.js | 44 + app/client/widgets/CheckBoxEditor.js | 21 + app/client/widgets/ChoiceEditor.js | 27 + app/client/widgets/ChoiceTextBox.js | 96 ++ app/client/widgets/DateEditor.js | 156 ++ app/client/widgets/DateTextBox.js | 89 + app/client/widgets/DateTimeEditor.css | 28 + app/client/widgets/DateTimeEditor.js | 118 ++ app/client/widgets/DateTimeTextBox.js | 120 ++ app/client/widgets/DiffBox.ts | 128 ++ app/client/widgets/EditorPlacement.ts | 104 ++ app/client/widgets/ErrorDom.ts | 19 + app/client/widgets/FieldBuilder.css | 32 + app/client/widgets/FieldBuilder.ts | 498 ++++++ app/client/widgets/FieldEditor.ts | 267 +++ app/client/widgets/FormulaEditor.ts | 175 ++ app/client/widgets/HyperLinkEditor.ts | 13 + app/client/widgets/HyperLinkTextBox.js | 78 + app/client/widgets/NTextBox.ts | 66 + app/client/widgets/NTextEditor.ts | 98 ++ app/client/widgets/NewAbstractWidget.ts | 107 ++ app/client/widgets/NewBaseEditor.ts | 86 + app/client/widgets/NumericTextBox.ts | 200 +++ app/client/widgets/PreviewModal.ts | 405 +++++ app/client/widgets/PreviewsWidget.css | 12 + app/client/widgets/PreviewsWidget.ts | 247 +++ app/client/widgets/Reference.css | 4 + app/client/widgets/Reference.js | 112 ++ app/client/widgets/ReferenceEditor.ts | 247 +++ app/client/widgets/Spinner.css | 3 + app/client/widgets/Spinner.ts | 32 + app/client/widgets/Switch.css | 48 + app/client/widgets/Switch.js | 37 + app/client/widgets/TextBox.css | 51 + app/client/widgets/TextBox.js | 71 + app/client/widgets/TextEditor.css | 80 + app/client/widgets/TextEditor.js | 108 ++ app/client/widgets/UserType.js | 236 +++ app/client/widgets/UserTypeImpl.js | 49 + app/common/BinaryIndexedTree.js | 16 +- app/server/lib/ILoginSession.ts | 1 + buildtools/webpack.config.js | 4 +- package.json | 23 +- static/error.html | 15 + static/hljs.default.css | 1 + static/icons/grist.icns | Bin 0 -> 145854 bytes static/icons/grist.ico | Bin 0 -> 80776 bytes static/icons/grist.png | Bin 0 -> 52861 bytes static/icons/grist.svg | 101 ++ static/icons/gristdoc.icns | Bin 0 -> 189344 bytes static/icons/gristdoc.ico | Bin 0 -> 81805 bytes static/icons/gristdoc.svg | 1435 +++++++++++++++++ static/icons/icon.icns | 1 + static/icons/icons.css | 91 ++ static/icons/logo-efcr.png | Bin 0 -> 65531 bytes static/icons/logo-fieldlink.png | Bin 0 -> 34577 bytes static/img/gplaypattern.png | Bin 0 -> 12047 bytes static/img/grist.png | Bin 0 -> 10746 bytes static/ui-icons/Charts/ChartArea.svg | 9 + static/ui-icons/Charts/ChartBar.svg | 9 + static/ui-icons/Charts/ChartKaplan.svg | 9 + static/ui-icons/Charts/ChartLine.svg | 9 + static/ui-icons/Charts/ChartPie.svg | 9 + static/ui-icons/Data Types/TypeCard.svg | 9 + static/ui-icons/Data Types/TypeCardList.svg | 11 + static/ui-icons/Data Types/TypeCell.svg | 9 + static/ui-icons/Data Types/TypeChart.svg | 9 + static/ui-icons/Data Types/TypeCustom.svg | 9 + static/ui-icons/Data Types/TypeDetails.svg | 9 + static/ui-icons/Data Types/TypeTable.svg | 9 + static/ui-icons/Fields/FieldAny.svg | 9 + static/ui-icons/Fields/FieldAttachment.svg | 9 + static/ui-icons/Fields/FieldCheckbox.svg | 9 + static/ui-icons/Fields/FieldChoice.svg | 9 + static/ui-icons/Fields/FieldColumn.svg | 9 + static/ui-icons/Fields/FieldDate.svg | 9 + static/ui-icons/Fields/FieldDateTime.svg | 9 + static/ui-icons/Fields/FieldFunction.svg | 9 + static/ui-icons/Fields/FieldFunctionEqual.svg | 9 + static/ui-icons/Fields/FieldInteger.svg | 9 + static/ui-icons/Fields/FieldLink.svg | 9 + static/ui-icons/Fields/FieldNumeric.svg | 9 + static/ui-icons/Fields/FieldReference.svg | 9 + static/ui-icons/Fields/FieldSpinner.svg | 9 + static/ui-icons/Fields/FieldSwitcher.svg | 9 + static/ui-icons/Fields/FieldTable.svg | 9 + static/ui-icons/Fields/FieldText.svg | 9 + static/ui-icons/Fields/FieldTextbox.svg | 9 + static/ui-icons/Fields/FieldToggle.svg | 9 + static/ui-icons/Logo/GristLogo.svg | 38 + static/ui-icons/Logo/ThumbPreview.svg | 28 + static/ui-icons/UI/CenterAlign.svg | 9 + static/ui-icons/UI/Code.svg | 11 + static/ui-icons/UI/Collapse.svg | 9 + static/ui-icons/UI/Convert.svg | 9 + static/ui-icons/UI/Copy.svg | 9 + static/ui-icons/UI/CrossBig.svg | 9 + static/ui-icons/UI/CrossSmall.svg | 9 + static/ui-icons/UI/Dots.svg | 9 + static/ui-icons/UI/Download.svg | 9 + static/ui-icons/UI/DragDrop.svg | 9 + static/ui-icons/UI/Dropdown.svg | 9 + static/ui-icons/UI/DropdownUp.svg | 9 + static/ui-icons/UI/Expand.svg | 9 + static/ui-icons/UI/EyeHide.svg | 11 + static/ui-icons/UI/EyeShow.svg | 9 + static/ui-icons/UI/Feedback.svg | 9 + static/ui-icons/UI/Filter.svg | 9 + static/ui-icons/UI/FilterSimple.svg | 13 + static/ui-icons/UI/Folder.svg | 9 + static/ui-icons/UI/FunctionResult.svg | 9 + static/ui-icons/UI/Help.svg | 11 + static/ui-icons/UI/Home.svg | 9 + static/ui-icons/UI/Idea.svg | 9 + static/ui-icons/UI/Import.svg | 9 + static/ui-icons/UI/LeftAlign.svg | 9 + static/ui-icons/UI/Log.svg | 11 + static/ui-icons/UI/Mail.svg | 11 + static/ui-icons/UI/Minus.svg | 9 + static/ui-icons/UI/NewNotification.svg | 9 + static/ui-icons/UI/Notification.svg | 9 + static/ui-icons/UI/Offline.svg | 9 + static/ui-icons/UI/Page.svg | 9 + static/ui-icons/UI/PanelLeft.svg | 9 + static/ui-icons/UI/PanelRight.svg | 9 + static/ui-icons/UI/PinBig.svg | 9 + static/ui-icons/UI/PinSmall.svg | 9 + static/ui-icons/UI/Pivot.svg | 9 + static/ui-icons/UI/Plus.svg | 9 + static/ui-icons/UI/Public.svg | 17 + static/ui-icons/UI/PublicFilled.svg | 17 + static/ui-icons/UI/Redo.svg | 9 + static/ui-icons/UI/Remove.svg | 9 + static/ui-icons/UI/Repl.svg | 11 + static/ui-icons/UI/ResizePanel.svg | 9 + static/ui-icons/UI/RightAlign.svg | 9 + static/ui-icons/UI/Search.svg | 9 + static/ui-icons/UI/Settings.svg | 9 + static/ui-icons/UI/Share.svg | 9 + static/ui-icons/UI/Sort.svg | 9 + static/ui-icons/UI/Tick.svg | 9 + static/ui-icons/UI/Undo.svg | 9 + static/ui-icons/UI/Validation.svg | 11 + static/ui-icons/UI/Video.svg | 9 + static/ui-icons/UI/Widget.svg | 9 + static/ui-icons/UI/Wrap.svg | 9 + static/ui-icons/UI/Zoom.svg | 9 + stubs/app/server/lib/LoginSession.ts | 8 +- 395 files changed, 52651 insertions(+), 47 deletions(-) create mode 100644 app/client/app.css create mode 100644 app/client/app.js create mode 100644 app/client/components/AceEditor.css create mode 100644 app/client/components/AceEditor.js create mode 100644 app/client/components/ActionLog.css create mode 100644 app/client/components/ActionLog.ts create mode 100644 app/client/components/Base.js create mode 100644 app/client/components/BaseView.js create mode 100644 app/client/components/ChartView.css create mode 100644 app/client/components/ChartView.ts create mode 100644 app/client/components/ClientScope.ts create mode 100644 app/client/components/Clipboard.css create mode 100644 app/client/components/Clipboard.js create mode 100644 app/client/components/CodeEditorPanel.css create mode 100644 app/client/components/CodeEditorPanel.js create mode 100644 app/client/components/ColumnFilters.css create mode 100644 app/client/components/ColumnTransform.ts create mode 100644 app/client/components/Comm.ts create mode 100644 app/client/components/Confirm.ts create mode 100644 app/client/components/CopySelection.js create mode 100644 app/client/components/Cursor.ts create mode 100644 app/client/components/CustomView.css create mode 100644 app/client/components/CustomView.ts create mode 100644 app/client/components/DetailView.css create mode 100644 app/client/components/DetailView.js create mode 100644 app/client/components/DocComm.ts create mode 100644 app/client/components/DocConfigTab.js create mode 100644 app/client/components/DocList.css create mode 100644 app/client/components/DocList.js create mode 100644 app/client/components/EmbedForm.css create mode 100644 app/client/components/EmbedForm.js create mode 100644 app/client/components/FieldConfigTab.css create mode 100644 app/client/components/FieldConfigTab.js create mode 100644 app/client/components/FormulaTransform.ts create mode 100644 app/client/components/GridView.css create mode 100644 app/client/components/GridView.js create mode 100644 app/client/components/GristDoc.css create mode 100644 app/client/components/GristDoc.ts create mode 100644 app/client/components/GristWSConnection.ts create mode 100644 app/client/components/Importer.ts create mode 100644 app/client/components/Layout.css create mode 100644 app/client/components/Layout.js create mode 100644 app/client/components/LayoutEditor.css create mode 100644 app/client/components/LayoutEditor.js create mode 100644 app/client/components/LayoutPreview.css create mode 100644 app/client/components/LayoutPreview.js create mode 100644 app/client/components/LinkingState.js create mode 100644 app/client/components/Login.css create mode 100644 app/client/components/Login.js create mode 100644 app/client/components/ModalDialog.js create mode 100644 app/client/components/ParseOptions.ts create mode 100644 app/client/components/Preferences.css create mode 100644 app/client/components/ProfileForm.js create mode 100644 app/client/components/REPLTab.css create mode 100644 app/client/components/REPLTab.js create mode 100644 app/client/components/RecordLayout.css create mode 100644 app/client/components/RecordLayout.js create mode 100644 app/client/components/RecordLayoutEditor.js create mode 100644 app/client/components/RefSelect.js create mode 100644 app/client/components/SearchBar.css create mode 100644 app/client/components/SearchBar.ts create mode 100644 app/client/components/Selector.js create mode 100644 app/client/components/SummaryConfig.js create mode 100644 app/client/components/TypeConversion.ts create mode 100644 app/client/components/TypeTransform.ts create mode 100644 app/client/components/UndoStack.ts create mode 100644 app/client/components/ValidationPanel.css create mode 100644 app/client/components/ValidationPanel.js create mode 100644 app/client/components/ViewConfigTab.css create mode 100644 app/client/components/ViewConfigTab.js create mode 100644 app/client/components/ViewLayout.css create mode 100644 app/client/components/ViewLayout.ts create mode 100644 app/client/components/ViewLinker.css create mode 100644 app/client/components/ViewLinker.js create mode 100644 app/client/components/ViewPane.ts create mode 100644 app/client/components/commandList.js create mode 100644 app/client/components/commands.css create mode 100644 app/client/components/commands.js create mode 100644 app/client/components/duplicatePage.ts create mode 100644 app/client/components/viewCommon.css create mode 100644 app/client/components/viewCommon.js create mode 100644 app/client/declarations.d.ts create mode 100644 app/client/exposeModulesForTests.js delete mode 100644 app/client/index.ts create mode 100644 app/client/lib/ACIndex.ts create mode 100644 app/client/lib/CustomSectionElement.ts create mode 100644 app/client/lib/Delay.ts create mode 100644 app/client/lib/DocPluginManager.ts create mode 100644 app/client/lib/ImportSourceElement.ts create mode 100644 app/client/lib/Mousetrap.js create mode 100644 app/client/lib/ObservableMap.js create mode 100644 app/client/lib/ObservableSet.js create mode 100644 app/client/lib/SafeBrowser.ts create mode 100644 app/client/lib/SafeBrowserProcess.css create mode 100644 app/client/lib/autocomplete.ts create mode 100644 app/client/lib/browserGlobals.js create mode 100644 app/client/lib/browserInfo.ts create mode 100644 app/client/lib/chartUtil.ts create mode 100644 app/client/lib/copyToClipboard.ts create mode 100644 app/client/lib/dispose.d.ts create mode 100644 app/client/lib/dispose.js create mode 100644 app/client/lib/dom.js create mode 100644 app/client/lib/domAsync.ts create mode 100644 app/client/lib/download.js create mode 100644 app/client/lib/fromKoSave.ts create mode 100644 app/client/lib/guessTimezone.ts create mode 100644 app/client/lib/helpScout.ts create mode 100644 app/client/lib/imports.d.ts create mode 100644 app/client/lib/imports.js create mode 100644 app/client/lib/koArray.d.ts create mode 100644 app/client/lib/koArray.js create mode 100644 app/client/lib/koArrayWrap.ts create mode 100644 app/client/lib/koDom.js create mode 100644 app/client/lib/koDomScrolly.css create mode 100644 app/client/lib/koDomScrolly.js create mode 100644 app/client/lib/koForm.css create mode 100644 app/client/lib/koForm.js create mode 100644 app/client/lib/koSession.js create mode 100644 app/client/lib/koUtil.js create mode 100644 app/client/lib/listEntry.ts create mode 100644 app/client/lib/loadScript.js create mode 100644 app/client/lib/localStorageObs.ts create mode 100644 app/client/lib/multiselect.css create mode 100644 app/client/lib/multiselect.js create mode 100644 app/client/lib/sessionObs.ts create mode 100644 app/client/lib/sortUtil.ts create mode 100644 app/client/lib/tableUtil.js create mode 100644 app/client/lib/testState.ts create mode 100644 app/client/lib/uploads.ts create mode 100644 app/client/models/AppModel.ts create mode 100644 app/client/models/BaseRowModel.js create mode 100644 app/client/models/BillingModel.ts create mode 100644 app/client/models/ClientColumnGetters.ts create mode 100644 app/client/models/ColumnACIndexes.ts create mode 100644 app/client/models/ColumnFilter.ts create mode 100644 app/client/models/ConnectState.ts create mode 100644 app/client/models/DataRowModel.ts create mode 100644 app/client/models/DataTableModel.js create mode 100644 app/client/models/DataTableModelWithDiff.ts create mode 100644 app/client/models/DocData.ts create mode 100644 app/client/models/DocListModel.js create mode 100644 app/client/models/DocModel.ts create mode 100644 app/client/models/DocPageModel.ts create mode 100644 app/client/models/HomeModel.ts create mode 100644 app/client/models/MetaRowModel.js create mode 100644 app/client/models/MetaTableModel.js create mode 100644 app/client/models/NotifyModel.ts create mode 100644 app/client/models/QuerySet.ts create mode 100644 app/client/models/SearchModel.ts create mode 100644 app/client/models/SectionFilter.ts create mode 100644 app/client/models/TableData.ts create mode 100644 app/client/models/TableModel.js create mode 100644 app/client/models/TreeModel.ts create mode 100644 app/client/models/UserManagerModel.ts create mode 100644 app/client/models/UserPrefs.ts create mode 100644 app/client/models/WorkspaceInfo.ts create mode 100644 app/client/models/entities/ACLMembershipRec.ts create mode 100644 app/client/models/entities/ACLPrincipalRec.ts create mode 100644 app/client/models/entities/ACLResourceRec.ts create mode 100644 app/client/models/entities/ColumnRec.ts create mode 100644 app/client/models/entities/DocInfoRec.ts create mode 100644 app/client/models/entities/PageRec.ts create mode 100644 app/client/models/entities/REPLRec.ts create mode 100644 app/client/models/entities/TabBarRec.ts create mode 100644 app/client/models/entities/TableRec.ts create mode 100644 app/client/models/entities/TableViewRec.ts create mode 100644 app/client/models/entities/ValidationRec.ts create mode 100644 app/client/models/entities/ViewFieldRec.ts create mode 100644 app/client/models/entities/ViewRec.ts create mode 100644 app/client/models/entities/ViewSectionRec.ts create mode 100644 app/client/models/errors.ts create mode 100644 app/client/models/gristConfigCache.ts create mode 100644 app/client/models/modelUtil.js create mode 100644 app/client/models/rowset.ts create mode 100644 app/client/models/rowuid.js create mode 100644 app/client/ui/AccessRules.ts create mode 100644 app/client/ui/AccountWidget.ts create mode 100644 app/client/ui/AddNewButton.ts create mode 100644 app/client/ui/ApiKey.ts create mode 100644 app/client/ui/App.css create mode 100644 app/client/ui/App.ts create mode 100644 app/client/ui/AppHeader.ts create mode 100644 app/client/ui/AppUI.ts create mode 100644 app/client/ui/BillingForm.ts create mode 100644 app/client/ui/BillingPage.ts create mode 100644 app/client/ui/BillingPageCss.ts create mode 100644 app/client/ui/BillingPlanManagers.ts create mode 100644 app/client/ui/ColumnFilterMenu.ts create mode 100644 app/client/ui/CustomThemes.ts create mode 100644 app/client/ui/DocHistory.ts create mode 100644 app/client/ui/DocMenu.ts create mode 100644 app/client/ui/DocMenuCss.ts create mode 100644 app/client/ui/Document.css create mode 100644 app/client/ui/DocumentSettings.ts create mode 100644 app/client/ui/ExampleCard.ts create mode 100644 app/client/ui/ExampleInfo.ts create mode 100644 app/client/ui/FieldMenus.ts create mode 100644 app/client/ui/FileDialog.ts create mode 100644 app/client/ui/GridOptions.ts create mode 100644 app/client/ui/GridViewMenus.ts create mode 100644 app/client/ui/HomeImports.ts create mode 100644 app/client/ui/HomeIntro.ts create mode 100644 app/client/ui/HomeLeftPane.ts create mode 100644 app/client/ui/LeftPanelCommon.ts create mode 100644 app/client/ui/MakeCopyMenu.ts create mode 100644 app/client/ui/MultiSelector.ts create mode 100644 app/client/ui/NotifyUI.ts create mode 100644 app/client/ui/PageWidgetPicker.ts create mode 100644 app/client/ui/Pages.ts create mode 100644 app/client/ui/PinnedDocs.ts create mode 100644 app/client/ui/ProfileDialog.ts create mode 100644 app/client/ui/RightPanel.ts create mode 100644 app/client/ui/ShareMenu.ts create mode 100644 app/client/ui/Tools.ts create mode 100644 app/client/ui/TopBar.ts create mode 100644 app/client/ui/TopBarCss.ts create mode 100644 app/client/ui/TreeViewComponent.ts create mode 100644 app/client/ui/TreeViewComponentCss.ts create mode 100644 app/client/ui/UserImage.ts create mode 100644 app/client/ui/UserManager.ts create mode 100644 app/client/ui/ViewLayoutMenu.ts create mode 100644 app/client/ui/ViewSectionMenu.ts create mode 100644 app/client/ui/VisibleFieldsConfig.ts create mode 100644 app/client/ui/WelcomePage.ts create mode 100644 app/client/ui/buttons.ts create mode 100644 app/client/ui/errorPages.ts create mode 100644 app/client/ui/modals.ts create mode 100644 app/client/ui/selectBy.ts create mode 100644 app/client/ui/shadowScroll.ts create mode 100644 app/client/ui/tooltips.ts create mode 100644 app/client/ui/transientInput.ts create mode 100644 app/client/ui/widgetTypes.ts create mode 100644 app/client/ui2018/breadcrumbs.ts create mode 100644 app/client/ui2018/buttonSelect.ts create mode 100644 app/client/ui2018/buttons.ts create mode 100644 app/client/ui2018/checkbox.ts create mode 100644 app/client/ui2018/draggableList.ts create mode 100644 app/client/ui2018/editableLabel.ts create mode 100644 app/client/ui2018/links.ts create mode 100644 app/client/ui2018/loaders.ts create mode 100644 app/client/ui2018/menus.ts create mode 100644 app/client/ui2018/modals.ts create mode 100644 app/client/ui2018/pages.ts create mode 100644 app/client/ui2018/search.ts create mode 100644 app/client/widgets/AbstractWidget.js create mode 100644 app/client/widgets/BaseEditor.js create mode 100644 app/client/widgets/CheckBox.css create mode 100644 app/client/widgets/CheckBox.js create mode 100644 app/client/widgets/CheckBoxEditor.js create mode 100644 app/client/widgets/ChoiceEditor.js create mode 100644 app/client/widgets/ChoiceTextBox.js create mode 100644 app/client/widgets/DateEditor.js create mode 100644 app/client/widgets/DateTextBox.js create mode 100644 app/client/widgets/DateTimeEditor.css create mode 100644 app/client/widgets/DateTimeEditor.js create mode 100644 app/client/widgets/DateTimeTextBox.js create mode 100644 app/client/widgets/DiffBox.ts create mode 100644 app/client/widgets/EditorPlacement.ts create mode 100644 app/client/widgets/ErrorDom.ts create mode 100644 app/client/widgets/FieldBuilder.css create mode 100644 app/client/widgets/FieldBuilder.ts create mode 100644 app/client/widgets/FieldEditor.ts create mode 100644 app/client/widgets/FormulaEditor.ts create mode 100644 app/client/widgets/HyperLinkEditor.ts create mode 100644 app/client/widgets/HyperLinkTextBox.js create mode 100644 app/client/widgets/NTextBox.ts create mode 100644 app/client/widgets/NTextEditor.ts create mode 100644 app/client/widgets/NewAbstractWidget.ts create mode 100644 app/client/widgets/NewBaseEditor.ts create mode 100644 app/client/widgets/NumericTextBox.ts create mode 100644 app/client/widgets/PreviewModal.ts create mode 100644 app/client/widgets/PreviewsWidget.css create mode 100644 app/client/widgets/PreviewsWidget.ts create mode 100644 app/client/widgets/Reference.css create mode 100644 app/client/widgets/Reference.js create mode 100644 app/client/widgets/ReferenceEditor.ts create mode 100644 app/client/widgets/Spinner.css create mode 100644 app/client/widgets/Spinner.ts create mode 100644 app/client/widgets/Switch.css create mode 100644 app/client/widgets/Switch.js create mode 100644 app/client/widgets/TextBox.css create mode 100644 app/client/widgets/TextBox.js create mode 100644 app/client/widgets/TextEditor.css create mode 100644 app/client/widgets/TextEditor.js create mode 100644 app/client/widgets/UserType.js create mode 100644 app/client/widgets/UserTypeImpl.js create mode 100644 static/error.html create mode 120000 static/hljs.default.css create mode 100644 static/icons/grist.icns create mode 100644 static/icons/grist.ico create mode 100644 static/icons/grist.png create mode 100644 static/icons/grist.svg create mode 100644 static/icons/gristdoc.icns create mode 100644 static/icons/gristdoc.ico create mode 100644 static/icons/gristdoc.svg create mode 120000 static/icons/icon.icns create mode 100644 static/icons/icons.css create mode 100644 static/icons/logo-efcr.png create mode 100644 static/icons/logo-fieldlink.png create mode 100644 static/img/gplaypattern.png create mode 100644 static/img/grist.png create mode 100644 static/ui-icons/Charts/ChartArea.svg create mode 100644 static/ui-icons/Charts/ChartBar.svg create mode 100644 static/ui-icons/Charts/ChartKaplan.svg create mode 100644 static/ui-icons/Charts/ChartLine.svg create mode 100644 static/ui-icons/Charts/ChartPie.svg create mode 100644 static/ui-icons/Data Types/TypeCard.svg create mode 100644 static/ui-icons/Data Types/TypeCardList.svg create mode 100644 static/ui-icons/Data Types/TypeCell.svg create mode 100644 static/ui-icons/Data Types/TypeChart.svg create mode 100644 static/ui-icons/Data Types/TypeCustom.svg create mode 100644 static/ui-icons/Data Types/TypeDetails.svg create mode 100644 static/ui-icons/Data Types/TypeTable.svg create mode 100644 static/ui-icons/Fields/FieldAny.svg create mode 100644 static/ui-icons/Fields/FieldAttachment.svg create mode 100644 static/ui-icons/Fields/FieldCheckbox.svg create mode 100644 static/ui-icons/Fields/FieldChoice.svg create mode 100644 static/ui-icons/Fields/FieldColumn.svg create mode 100644 static/ui-icons/Fields/FieldDate.svg create mode 100644 static/ui-icons/Fields/FieldDateTime.svg create mode 100644 static/ui-icons/Fields/FieldFunction.svg create mode 100644 static/ui-icons/Fields/FieldFunctionEqual.svg create mode 100644 static/ui-icons/Fields/FieldInteger.svg create mode 100644 static/ui-icons/Fields/FieldLink.svg create mode 100644 static/ui-icons/Fields/FieldNumeric.svg create mode 100644 static/ui-icons/Fields/FieldReference.svg create mode 100644 static/ui-icons/Fields/FieldSpinner.svg create mode 100644 static/ui-icons/Fields/FieldSwitcher.svg create mode 100644 static/ui-icons/Fields/FieldTable.svg create mode 100644 static/ui-icons/Fields/FieldText.svg create mode 100644 static/ui-icons/Fields/FieldTextbox.svg create mode 100644 static/ui-icons/Fields/FieldToggle.svg create mode 100644 static/ui-icons/Logo/GristLogo.svg create mode 100644 static/ui-icons/Logo/ThumbPreview.svg create mode 100644 static/ui-icons/UI/CenterAlign.svg create mode 100644 static/ui-icons/UI/Code.svg create mode 100644 static/ui-icons/UI/Collapse.svg create mode 100644 static/ui-icons/UI/Convert.svg create mode 100644 static/ui-icons/UI/Copy.svg create mode 100644 static/ui-icons/UI/CrossBig.svg create mode 100644 static/ui-icons/UI/CrossSmall.svg create mode 100644 static/ui-icons/UI/Dots.svg create mode 100644 static/ui-icons/UI/Download.svg create mode 100644 static/ui-icons/UI/DragDrop.svg create mode 100644 static/ui-icons/UI/Dropdown.svg create mode 100644 static/ui-icons/UI/DropdownUp.svg create mode 100644 static/ui-icons/UI/Expand.svg create mode 100644 static/ui-icons/UI/EyeHide.svg create mode 100644 static/ui-icons/UI/EyeShow.svg create mode 100644 static/ui-icons/UI/Feedback.svg create mode 100644 static/ui-icons/UI/Filter.svg create mode 100644 static/ui-icons/UI/FilterSimple.svg create mode 100644 static/ui-icons/UI/Folder.svg create mode 100644 static/ui-icons/UI/FunctionResult.svg create mode 100644 static/ui-icons/UI/Help.svg create mode 100644 static/ui-icons/UI/Home.svg create mode 100644 static/ui-icons/UI/Idea.svg create mode 100644 static/ui-icons/UI/Import.svg create mode 100644 static/ui-icons/UI/LeftAlign.svg create mode 100644 static/ui-icons/UI/Log.svg create mode 100644 static/ui-icons/UI/Mail.svg create mode 100644 static/ui-icons/UI/Minus.svg create mode 100644 static/ui-icons/UI/NewNotification.svg create mode 100644 static/ui-icons/UI/Notification.svg create mode 100644 static/ui-icons/UI/Offline.svg create mode 100644 static/ui-icons/UI/Page.svg create mode 100644 static/ui-icons/UI/PanelLeft.svg create mode 100644 static/ui-icons/UI/PanelRight.svg create mode 100644 static/ui-icons/UI/PinBig.svg create mode 100644 static/ui-icons/UI/PinSmall.svg create mode 100644 static/ui-icons/UI/Pivot.svg create mode 100644 static/ui-icons/UI/Plus.svg create mode 100644 static/ui-icons/UI/Public.svg create mode 100644 static/ui-icons/UI/PublicFilled.svg create mode 100644 static/ui-icons/UI/Redo.svg create mode 100644 static/ui-icons/UI/Remove.svg create mode 100644 static/ui-icons/UI/Repl.svg create mode 100644 static/ui-icons/UI/ResizePanel.svg create mode 100644 static/ui-icons/UI/RightAlign.svg create mode 100644 static/ui-icons/UI/Search.svg create mode 100644 static/ui-icons/UI/Settings.svg create mode 100644 static/ui-icons/UI/Share.svg create mode 100644 static/ui-icons/UI/Sort.svg create mode 100644 static/ui-icons/UI/Tick.svg create mode 100644 static/ui-icons/UI/Undo.svg create mode 100644 static/ui-icons/UI/Validation.svg create mode 100644 static/ui-icons/UI/Video.svg create mode 100644 static/ui-icons/UI/Widget.svg create mode 100644 static/ui-icons/UI/Wrap.svg create mode 100644 static/ui-icons/UI/Zoom.svg diff --git a/app/client/app.css b/app/client/app.css new file mode 100644 index 00000000..0a2f6c2d --- /dev/null +++ b/app/client/app.css @@ -0,0 +1,174 @@ +/* global variables */ +:root { + --color-logo-row: #F9AE41; + --color-logo-col: #2CB0AF; + --color-logo-cell: #DEDDDD; + --color-logo-bg: #42494B; + + --color-link-default: #336; + --color-link-visited: #336; + --color-link-hover: #66c; + --color-link-active: #66c; + + --color-link-bright: orange; + + --color-start-page-bg: #f0f0f0; + --color-doclist-bg: #fcfcfc; + + --color-navbar-bg: var(--color-logo-bg); + --color-navbar-btn-bg: #fefefe; + --color-navbar-btn-bg-hover: #f6f6f6; + --color-navbar-btn-disabled: #ccc; + + --color-header-doclist: #6d6dde; + + --color-tab-bar-bg: #d6d6d6; + + --color-border-light: #ddd; + --color-border-medium: #bbb; + + --color-btn-login: #ffb749; + --color-btn-login-background: #fff1dc; + --color-btn-createdoc: #3fda2c; + --color-btn-uploaddoc: #00dcff; + --color-btn-decline: #c74646; + --color-btn-accept: #3eda2c; + + --layout-top-spacer: 20px; + --layout-doclist-header-height: 42px; + --layout-doclist-height: calc(100vh - 2*var(--layout-top-spacer)); + + --color-list-row-hover: #f0f0f0; + + --color-list-item: #f6f6f6; + --color-list-item-hover: #e0e0e0; + --color-list-item-selected: #e8d53d; + --color-list-item-disabled: #ccc; + --color-list-item-action: #6eec6e; + + --color-hint-text: #888; + + --scroll-bar-width: 12px; + --scroll-bar-bg: #f0f0f0; + + /* fonts */ + --font-navbar-title: "Helvetica", "Arial", sans-serif; + --font-btn-symbols: "Apple Symbols", "Arial Unicode MS"; +} + + + +.flexhbox { + display: -webkit-flex; + display: flex; +} +.flexvbox { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; +} +.flexitem { + /* Makes the flex item flexible and sets the flex basis to zero (disregards content size). */ + -webkit-flex: 1 1 0px; + flex: 1 1 0px; + /* Min-width of 0 is needed to allow the flex box to shrink below its minimum content size. */ + min-width: 0px; +} +.flexnone { + /* Sizes the item based on content or width/height, and makes it fully inflexible. */ + -webkit-flex: none; + flex: none; +} +.flexauto { + /* Sizes the item based on content or width/height, and makes it fully flexible. */ + -webkit-flex: auto; + flex: auto; +} +.clipped { + overflow: hidden; +} + +body { + /* This seems logically appropriate since we never want body to scroll, but the real reason is + * to avoid a major slowdown when using $().modal() dialogs (a JQuery plugin in bootstrap). + * Those add/remove a class to body which sets "overflow: hidden", which causes great slowness on + * Firefox (not Chrome). If body is already "overflow: hidden", it's much faster. + */ + overflow: hidden; +} + +.show_scrollbar::-webkit-scrollbar { + width: var(--scroll-bar-width); + height: var(--scroll-bar-width); + background-color: var(--scroll-bar-bg); +} +.show_scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0,0.3); + -webkit-border-radius: 100px; + border: 2px solid var(--scroll-bar-bg); +} +.show_scrollbar::-webkit-scrollbar-thumb:vertical { + min-height: 4rem; +} +.show_scrollbar::-webkit-scrollbar-thumb:horizontal { + min-width: 4rem; +} +.show_scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(0,0,0,0.4); /* Some darker color when you click it */ + -webkit-border-radius: 100px; +} +.show_scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(0,0,0,0.5); /* Some darker color when you click it */ + -webkit-border-radius: 100px; +} +div.dev_warning { + position: absolute; + z-index: 10; + width: 100%; + opacity: 0.5; + pointer-events: none; + font-size: 200%; + color: white; + background: red; + text-align: center; +} +#browser-check-problem { + display: none; + width: 100%; + height: 100%; + position: absolute; + z-index: 5000; + top: 0; + left: 0; + background: rgba(255, 255, 255, 0.9); + padding-top: 3em; +} +#browser-check-problem div.browser-check-wrapper { + position: absolute; + top: 30%; + width: 100%; +} +#browser-check-problem div.browser-check-message, #browser-check-problem div.browser-check-options { + margin: 0 auto; + max-width: 400px; + padding: 1em; + background: white; +} +#browser-check-problem div.browser-check-options { + text-align: center; +} +#browser-check-problem a { + display: inline-block; + background: white; + padding: 10px; + margin: 10px; + color: #16B378; + border: 1px solid #16B378; + border-radius: 4px; +} +#browser-check-problem a:hover { + text-decoration: none; + color: #009058; + border: 1px solid #009058; +} diff --git a/app/client/app.js b/app/client/app.js new file mode 100644 index 00000000..25914109 --- /dev/null +++ b/app/client/app.js @@ -0,0 +1,33 @@ +/* global $, window, document */ + +const {App} = require('./ui/App'); + +// Disable longStackTraces, which seem to be enabled in the browser by default. +var bluebird = require('bluebird'); +bluebird.config({ longStackTraces: false }); + +// Set up integration between grainjs and knockout disposal. +const {setupKoDisposal} = require('grainjs'); +const ko = require('knockout'); +setupKoDisposal(ko); + +$(function() { + window.gristApp = App.create(null, document.getElementById('grist-app')); + // Set from the login tests to stub and un-stub functions during execution. + window.loginTestSandbox = null; + + // These modules are exposed for the sake of browser tests. + window.exposeModulesForTests = function() { + return (import('./exposeModulesForTests' /* webpackChunkName: "modulesForTests" */)); + }; + window.exposedModules = { + // Several existing tests use window.exposedModules.loadScript has loaded + // a file for them. We now load exposedModules asynchronously, so that it + // doesn't slow down application startup. To avoid changing tests + // unnecessarily, we implement a loadScript wrapper. + loadScript(name) { + return window.exposeModulesForTests() + .then(() => window.exposedModules._loadScript(name)); + } + }; +}); diff --git a/app/client/components/AceEditor.css b/app/client/components/AceEditor.css new file mode 100644 index 00000000..58132d99 --- /dev/null +++ b/app/client/components/AceEditor.css @@ -0,0 +1,18 @@ +.ace_grist_link_hidden { + display: none; +} + +.ace_grist_link { + color: var(--grist-color-light-green); + text-decoration: underline; + cursor: pointer; +} + +.ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link { + color: var(--grist-color-dark-green); +} + +.ace_editor.ace_autocomplete .ace_text-layer { + z-index: 7; + pointer-events: auto; +} diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js new file mode 100644 index 00000000..59712f4e --- /dev/null +++ b/app/client/components/AceEditor.js @@ -0,0 +1,402 @@ +var ace = require('brace'); +var ko = require('knockout'); +var _ = require('underscore'); +// Used to load python language settings and 'chrome' ace style +require('brace/mode/python'); +require('brace/theme/chrome'); +require('brace/ext/language_tools'); +var dom = require('../lib/dom'); +var dispose = require('../lib/dispose'); +var modelUtil = require('../models/modelUtil'); + +/** + * A class to help set up the ace editor with standard formatting and convenience functions + * @param {Observable} options.observable: If given, creates a 2-way binding between the observable + * and the value of the editor. + * @param {Boolean} options.saveValueOnBlurEvent: Flag to indicate whether ace editor + * should save the value on `blur` event. + * @param {Function} options.calcSize: Optional function used to resize the editor. It is called + * with (elem, desiredSize) as arguments, and should return the actual size to use for the + * element. Both desiredSize and the return value are objects with 'width' and 'height' members. + */ +function AceEditor(options) { + // Observable subscription is not created until the dom is built + this.observable = (options && options.observable) || null; + this.saveValueOnBlurEvent = !(options && (options.saveValueOnBlurEvent === false)); + this.calcSize = (options && options.calcSize) || ((elem, size) => size); + this.gristDoc = (options && options.gristDoc) || null; + + this.editor = null; + this.editorDom = null; + this.session = null; + this._setupCallback = null; + this._setupTimer = null; + + this.textPadding = 10; // Space after cursor when not using wrap mode +} +dispose.makeDisposable(AceEditor); + +// Builds editor dom with additional setup possible in function `optSetupCallback`. +// May be called multiple times by an instance of AceEditor. +AceEditor.prototype.buildDom = function(optSetupCallback) { + this._fullDom = dom('div.code_editor_container', + this.editorDom = dom('div') + ); + this._setupCallback = optSetupCallback; + this._setupTimer = setTimeout(() => this._setup(), 0); + return this._fullDom; +}; + +/** + * You may optionally call this once the DOM returned from buildDom is attached to the document to + * make setup and sizing more immediate. + */ +AceEditor.prototype.onAttach = function() { + if (this._setupTimer) { + clearTimeout(this._setupTimer); + this._setupTimer = null; + this._setup(); + } +}; + +AceEditor.prototype.writeObservable = function() { + if (this.observable) { + modelUtil.setSaveValue(this.observable, this.getValue()); + } +}; + +AceEditor.prototype.getEditor = function() { + return this.editor; +}; + +AceEditor.prototype.getValue = function() { + return this.editor && this.editor.getValue(); +}; + +/** + * @param {String} val: The new value to set the editor to. + * @param {Number} optCursorPos: Position where to place the cursor: at the end if omitted. + */ +AceEditor.prototype.setValue = function(val, optCursorPos) { + // Note that underlying setValue() has a special meaning for second parameter: + // undefined or 0 is selectAll, -1 is at the document start, and 1 is at the end. + this.editor.setValue(val, optCursorPos === 0 ? -1 : 1); + if (optCursorPos > 0 && optCursorPos < val.length) { + var pos = this.session.getDocument().indexToPosition(optCursorPos); + this.editor.moveCursorTo(pos.row, pos.column); + } +}; + +AceEditor.prototype.isBuilt = function() { + return this.editor !== null; +}; + +// Enables or disables the AceEditor +AceEditor.prototype.enable = function(bool) { + var editor = this.editor; + editor.setReadOnly(!bool); + editor.renderer.$cursorLayer.element.style.opacity = bool ? 100 : 0; + editor.gotoLine(Infinity, Infinity); // Prevents text selection on disable +}; + +/** + * Commands must be added specially to the ace editor. + * Attaching commands to the textarea using commandGroup.attach() only + * works for certain keys. + * + * Note: Commands to the aceEditor are always enabled. + * Note: Ace defers to standard behavior when false is returned. + */ +AceEditor.prototype.attachCommandGroup = function(commandGroup) { + _.each(commandGroup.knownKeys, (command, key) => { + this.editor.commands.addCommand({ + name: command, + bindKey: { + win: key, + mac: key, + sender: 'editor|cli' + }, + // AceEditor wants a command to return true if it got handled, whereas our command returns + // true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled. + exec: () => !commandGroup.commands[command]() + }); + }); +}; + +/** + * Attaches a command to the editor which saves the current editor + * contents to the attached observable on 'Shift+Enter'. + * Throws error if there is no attached observable. + * TODO: Use instead of custom save command for more implementations of AceEditor + */ +AceEditor.prototype.attachSaveCommand = function() { + if (!this.observable) { + throw new Error("Cannot attach save command to editor with no bound observable"); + } + var key = 'Shift+Enter'; + this.editor.commands.addCommand({ + name: 'saveFormula', + bindKey: { + win: key, + mac: key, + sender: 'editor|cli' + }, + // AceEditor wants a command to return true if it got handled + exec: () => { + this.writeObservable(); + return true; + } + }); +}; + +// Wraps words to the current width of the editor +AceEditor.prototype.adjustContentToWidth = function() { + var characterWidth = this.editor.renderer.characterWidth; + var contentWidth = this.editor.renderer.scroller.clientWidth; + + if(contentWidth > 0) { + this.editor.getSession().setWrapLimit(parseInt(contentWidth/characterWidth, 10) - 1); + } +}; + +AceEditor.prototype.setFontSize = function(pxVal) { + this.editor.setFontSize(pxVal); + this.resize(); +}; + +AceEditor.prototype._setup = function() { + // Standard editor setup + this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom)); + if (this.gristDoc) { + // Add some autocompletion with partial access to document + const aceLanguageTools = ace.acequire('ace/ext/language_tools'); + const gristDoc = this.gristDoc; + aceLanguageTools.setCompleters([]); + aceLanguageTools.addCompleter({ + // Default regexp stops at periods, which doesn't let autocomplete + // work on members. So we expand it to include periods. + // We also include $, which grist uses for column names. + identifierRegexps: [/[a-zA-Z_0-9.$\u00A2-\uFFFF]/], + + // For autocompletion we ship text to the sandbox and run standard completion there. + getCompletions: function(editor, session, pos, prefix, callback) { + if (prefix.length === 0) { callback(null, []); return; } + const tableId = gristDoc.viewModel.activeSection().table().tableId(); + gristDoc.docComm.autocomplete(prefix, tableId) + .then(suggestions => { + // ACE autocompletions are very poorly documented. This is somewhat helpful: + // https://prog.world/implementing-code-completion-in-ace-editor/ + callback(null, suggestions.map(suggestion => { + if (Array.isArray(suggestion)) { + const [funcname, argSpec, isGrist] = suggestion; + const meta = isGrist ? 'grist' : 'python'; + return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname}; + } else { + return {value: suggestion, score: 1, meta: "python"}; + } + })); + }); + }, + }); + + // Create Autocomplete object at this point so we can turn autoSelect off. + // There doesn't seem to be any way to get ace to respect autoSelect otherwise. + // It is important for autoSelect to be off so that hitting enter doesn't automatically + // use a suggestion, a change of behavior that doesn't seem particularly desirable and + // which also breaks several existing tests. + const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools + const completer = new Autocomplete(); + this.editor.completer = completer; + this.editor.completer.autoSelect = false; + aceCompleterAddHelpLinks(completer); + + // Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element + // it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any + // interface for this, so this takes some hacking. (One reason for this is that Ace seems to + // expect that a single AutoCompleter would be used for all editor instances.) + this.autoDisposeCallback(() => { + if (completer.editor) { + completer.detach(); + } + if (completer.popup) { + completer.popup.destroy(); // This is not enough, but seems relevant to call. + ko.removeNode(completer.popup.container); // Removes the element and cleans up JQuery state if any. + } + }); + } + this.editor.setOptions({ + enableLiveAutocompletion: true, // use autocompletion without needing special activation. + }); + this.session = this.editor.getSession(); + this.session.setMode('ace/mode/python'); + this.editor.setTheme('ace/theme/chrome'); + + // Default line numbers to hidden + this.editor.renderer.setShowGutter(false); + this.session.setTabSize(2); + this.session.setUseWrapMode(true); + + this.editor.on('change', this.resize.bind(this)); + this.editor.$blockScrolling = Infinity; + this.editor.setFontSize(11); + this.resize(); + + // Set up the bound observable if supplied + if (this.observable) { + var subscription = this.observable.subscribeInit(val => {if (val !== undefined) {this.setValue(val);}}); + // Dispose with dom since subscription is created when dom is created + dom(this.editorDom, + dom.autoDispose(subscription) + ); + + if (this.saveValueOnBlurEvent) { + this.editor.on('blur', () => { + this.writeObservable(); + }); + } + } + + if (this._setupCallback) { + this._setupCallback.call(null, this.editor); + this._setupCallback = null; + } +}; + +AceEditor.prototype.resize = function() { + var wrap = this.session.getUseWrapMode(); + var contentWidth = wrap ? 0 : this._getContentWidth(); + var desiredSize = { + width: wrap ? 0 : contentWidth + this.textPadding, + height: this._getContentHeight() + }; + var size = this.calcSize(this._fullDom, desiredSize); + if (size.width < contentWidth) { + // Editor will show a horizontal scrollbar, so recalculate to make space for it. + desiredSize.height += 20; + size = this.calcSize(this._fullDom, desiredSize); + } + + this.editorDom.style.width = size.width ? size.width + 'px' : 'auto'; + this.editorDom.style.height = size.height + 'px'; + this.editor.resize(); +}; + +AceEditor.prototype._getContentWidth = function() { + return this.session.getScreenWidth() * this.editor.renderer.characterWidth; +}; + +AceEditor.prototype._getContentHeight = function() { + return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight; +}; + + +let _RangeConstructor = null; //singleton, load it lazily +AceEditor.makeRange = function(a,b,c,d) { + _RangeConstructor = _RangeConstructor || ace.acequire('ace/range').Range; + return new _RangeConstructor(a,b,c,d); +}; + +/** + * When autocompleting a known function (with funcname received from the server call), turn the + * function name into a link to Grist documentation. + * + * ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by + * monkey-patching it. Further, the only text styling is done via styled tokens, but we can style + * them to look like links, and handle clicks to open the destination URL. + * + * This implementation relies a lot on the details of the implementation in + * node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it. + */ +function aceCompleterAddHelpLinks(completer) { + // Replace the $init function in order to intercept the creation of the autocomplete popup. + const init = completer.$init; + completer.$init = function() { + const popup = init.apply(this, arguments); + customizeAceCompleterPopup(this, popup); + return popup; + }; +} + +function customizeAceCompleterPopup(completer, popup) { + // Replace the $tokenizeRow function to produce customized tokens to style the link part. + const origTokenize = popup.session.bgTokenizer.$tokenizeRow; + popup.session.bgTokenizer.$tokenizeRow = function(row) { + const tokens = origTokenize(row); + return retokenizeAceCompleterRow(popup.data[row], tokens); + }; + + // Replace the click handler with one that handles link clicks. + popup.removeAllListeners("click"); + popup.on("click", function(e) { + if (!maybeAceCompleterLinkClick(e)) { + completer.insertMatch(); + } + e.stop(); + }); +} + +function retokenizeAceCompleterRow(rowData, tokens) { + if (!rowData.funcname) { + // Not a special completion, pass through the result of ACE's original tokenizing. + return tokens; + } + + // ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions. + // We jump in, and further divide the tokens so that those that form the link get an extra CSS + // class. ACE's will turn token.type into CSS classes by splitting the type on "." and prefixing + // the resulting substrings with "ace_". + + // Funcname may be the recognized name itself (e.g. "UPPER"), or a method (like + // "Table1.lookupOne"), in which case only the portion after the dot is the recognized name. + + // Figure out the portion that should be linkified. + const dot = rowData.funcname.lastIndexOf("."); + const linkStart = dot < 0 ? 0 : dot + 1; + const linkEnd = rowData.funcname.length; + + const newTokens = []; + + // Include into new tokens a special token that will be hidden, but include the link URL. On + // click, we find it to know what URL to open. + const href = 'https://support.getgrist.com/functions/#' + + rowData.funcname.slice(linkStart, linkEnd).toLowerCase(); + newTokens.push({value: href, type: 'grist_link_hidden'}); + + // Go through tokens, splitting them if needed, and modifying those that form the link part. + let position = 0; + for (const t of tokens) { + // lStart/lEnd are indices of the link within the token, possibly negative. + const lStart = linkStart - position, lEnd = linkEnd - position; + if (lStart > 0) { + const beforeLink = t.value.slice(0, lStart); + newTokens.push({value: beforeLink, type: t.type}); + } + if (lEnd > 0) { + const inLink = t.value.slice(Math.max(0, lStart), lEnd); + const newType = t.type + (t.type ? '.' : '') + 'grist_link'; + newTokens.push({value: inLink, type: newType}); + } + if (lEnd < t.value.length) { + const afterLink = t.value.slice(lEnd); + newTokens.push({value: afterLink, type: t.type}); + } + position += t.value.length; + } + return newTokens; +} + +// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If +// so, we should be able to find the URL and open another window to it. +function maybeAceCompleterLinkClick(event) { + const tgt = event.domEvent.target; + if (tgt && tgt.matches('.ace_grist_link')) { + const dest = tgt.parentElement.querySelector('.ace_grist_link_hidden'); + if (dest) { + window.open(dest.textContent, "_blank"); + return true; + } + } + return false; +} + +module.exports = AceEditor; diff --git a/app/client/components/ActionLog.css b/app/client/components/ActionLog.css new file mode 100644 index 00000000..df0a3688 --- /dev/null +++ b/app/client/components/ActionLog.css @@ -0,0 +1,121 @@ +.action_log { + padding: 1rem; + margin: 0; +} + +.action_log_item { + list-style: none; + padding: 0; + margin: 0; + font-size: 1.1rem; +} + +.action_info { + line-height: 1; + font-size: 0.9rem; + color: grey; + margin-bottom: 4px; + margin-top: 8px; +} + +.action_info > span { + margin: 0 2px; +} + +.action_info_user { + font-weight: 600; +} + +.action_info_from_self { + color: #333333; +} + +.action_log_item.undone > .action_info, +.action_log_item.undone > .action_desc { + text-decoration: line-through; + color: #aaa; +} + +.action_log_item.buried { + background-color: #ddd; +} + +.action_log_item.buried > .action_desc { + text-decoration: line-through; + color: #aaa; +} + +.action_log_rename_pre { + background: #faa; +} + +.action_log_rename_post { + background: #afa; +} + +.action_log_table { + border-collapse: collapse; +} + +.action_log_table caption { + caption-side: bottom; + text-align: center; + margin-top: 0; + padding-top: 0; + color: #000; +} + +.action_log_table td { + border-left: 1px solid #888; + border-right: 1px solid #888; + border-bottom: 1px solid #888; + border-top: 1px solid #888; + cursor: pointer; +} + +.action_log_table th { + border-top: 1px solid #ccc; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + color: #888; +} + +.action_log_table th:first-child { + border: none; + border-bottom: 1px solid #ccc; +} + +.action_log_table td:first-child { + border: none; + border-left: 1px solid #ccc; + border-bottom: 1px solid #ccc; + color: #888; + cursor: inherit; +} + +.action_log_table td, .action_log_table th { + padding-left: 3px; + padding-right: 3px; + font-weight: normal; +} + +.action_log_cell_remove { + background: #faa; + text-decoration: line-through; + padding-left: 2px; + padding-right: 2px; +} + +.action_log_cell_pre { + margin-right: 3px; +} + +.action_log_cell_add { + background: #afa; + padding-left: 2px; + padding-right: 2px; +} + +.action_comment { + display: none; +} diff --git a/app/client/components/ActionLog.ts b/app/client/components/ActionLog.ts new file mode 100644 index 00000000..ce89d992 --- /dev/null +++ b/app/client/components/ActionLog.ts @@ -0,0 +1,445 @@ +/** + * ActionLog manages the list of actions from server and displays them in the side bar. + */ + +import * as dispose from 'app/client/lib/dispose'; +import * as dom from 'app/client/lib/dom'; +import {timeFormat} from 'app/common/timeFormat'; +import * as ko from 'knockout'; +import map = require('lodash/map'); + +import koArray from 'app/client/lib/koArray'; +import {KoArray} from 'app/client/lib/koArray'; +import * as koDom from 'app/client/lib/koDom'; +import * as koForm from 'app/client/lib/koForm'; + +import {GristDoc} from 'app/client/components/GristDoc'; +import {ActionGroup} from 'app/common/ActionGroup'; +import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables, + LabelDelta} from 'app/common/ActionSummary'; +import {CellDelta} from 'app/common/TabularDiff'; +import {IDomComponent} from 'grainjs'; + +/** + * + * Actions that are displayed in the log get a state observable + * to track if they are undone/buried. + * + * Also for each table shown in the log, we create an observable + * to track its name. References to these observables are stored + * with each action, by the name of the table at that time (the + * name of a table can change). + * + */ +export interface ActionGroupWithState extends ActionGroup { + state?: ko.Observable; // is action undone/buried + tableFilters?: {[tableId: string]: ko.Observable}; // current names of tables + affectedTableIds?: Array>; // names of tables affecting this ActionGroup +} + +const gristNotify = (window as any).gristNotify; + +// Action display state enum. +const state = { + UNDONE: 'undone', + BURIED: 'buried', + DEFAULT: 'default' +}; + +export class ActionLog extends dispose.Disposable implements IDomComponent { + + private _displayStack: KoArray; + private _gristDoc: GristDoc|null; + private _selectedTableId: ko.Computed; + private _showAllTables: ko.Observable; // should all tables be visible? + + private _pending: ActionGroupWithState[] = []; // cache for actions that arrive while loading log + private _loaded: boolean = false; // flag set once log is loaded + private _loading: ko.Observable; // flag set while log is loading + + /** + * Create an ActionLog. + * @param options - supplies the GristDoc holding the log, if we have one, so that we + * can cross-reference with it. We may not have a document, if used from the + * command line renderActions utility, in which case we don't set up cross-references. + */ + public create(options: {gristDoc: GristDoc|null}) { + // By default, just show actions for the currently viewed table. + this._showAllTables = ko.observable(false); + // We load the ActionLog lazily now, when it is first viewed. + this._loading = ko.observable(false); + + this._gristDoc = options.gristDoc; + + // TODO: _displayStack grows without bound within a single session. + // Stack of actions as they should be displayed to the user. + this._displayStack = koArray(); + + // Computed for the tableId of the table currently being viewed. + if (!this._gristDoc) { + this._selectedTableId = this.autoDispose(ko.computed(() => "")); + } else { + this._selectedTableId = this.autoDispose(ko.computed( + () => this._gristDoc!.viewModel.activeSection().table().tableId())); + } + } + + public buildDom() { + return this._buildLogDom(); + } + + /** + * Pushes actions as they are received from the server to the display stack. + * @param {Object} actionGroup - ActionGroup instance from the server. + */ + public pushAction(ag: ActionGroupWithState): void { + if (this._loading()) { + this._pending.push(ag); + return; + } + + this._setupFilters(ag, this._displayStack.at(0) || undefined); + const otherAg = ag.otherId ? this._displayStack.all().find(a => a.actionNum === ag.otherId) : null; + + if (otherAg) { + // Undo/redo action. + if (otherAg.state) { + otherAg.state(ag.isUndo ? state.UNDONE : state.DEFAULT); + } + } else { + // Any (non-link) action. + if (ag.fromSelf) { + // Bury all undos immediately preceding this action since they can no longer + // be redone. This is triggered by normal actions and undo/redo actions whose + // targets are not recent (not in the stack). + for (let i = 0; i < this._displayStack.peekLength; i++) { + const prevAction = this._displayStack.at(i)!; + if (!prevAction.state) { continue; } + const prevState = prevAction.state(); + if (prevAction.fromSelf && prevState === state.DEFAULT) { + // When a normal action is found, stop looking to bury previous actions. + break; + } else if (prevAction.fromSelf && prevState === state.UNDONE) { + // The previous action was undone, so now it has become buried. + prevAction.state(state.BURIED); + } + } + } + if (!ag.otherId) { + ag.state = ko.observable(state.DEFAULT); + this._displayStack.unshift(ag); + } + } + } + + /** + * Render a description of an action prepared on the server. + * @param {TabularDiffs} act - a collection of table changes + * @param {string} txt - a textual description of the action + * @param {ActionGroupWithState} ag - the full action information we have + */ + public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState) { + const act = asTabularDiffs(sum); + const editDom = dom('div', + this._renderTableSchemaChanges(sum, ag), + this._renderColumnSchemaChanges(sum, ag), + map(act, (tdiff, table) => { + if (tdiff.cells.length === 0) { return dom('div'); } + return dom('table.action_log_table', + koDom.show(() => this._showForTable(table, ag)), + dom('caption', + this._renderTableName(table)), + dom('tr', + dom('th'), + tdiff.header.map(diff => { + return dom('th', this._renderCell(diff)); + })), + tdiff.cells.map(row => { + return dom('tr', + dom('td', this._renderCell(row[0])), + row[2].map((diff, idx: number) => { + return dom('td', + this._renderCell(diff), + dom.on('click', () => { + return this._selectCell(row[1], act[table].header[idx], table, + ag ? ag.actionNum : 0); + })); + })); + })); + }), + dom('span.action_comment', txt)); + return editDom; + } + + /** + * Decorate an ActionGroup with observables for controlling visibility of any + * table information rendered from it. Observables are shared with the previous + * ActionGroup, and simply stored under a new name as needed. + */ + private _setupFilters(ag: ActionGroupWithState, prev?: ActionGroupWithState): void { + const filt: {[name: string]: ko.Observable} = ag.tableFilters = {}; + + // First, bring along observables for tables from previous actions. + if (prev) { + // Tables are renamed from time to time - prepare dictionary of updates. + const renames = new Map(ag.actionSummary.tableRenames); + for (const name of Object.keys(prev.tableFilters!)) { + if (name.startsWith('-')) { + // skip + } else if (renames.has(name)) { + const newName = renames.get(name) || defunctTableName(name); + filt[newName] = prev.tableFilters![name]; + filt[newName](newName); // Update the observable with the new name. + } else { + filt[name] = prev.tableFilters![name]; + } + } + } + // Add any more observables that we need for this action. + const names = getAffectedTables(ag.actionSummary); + for (const name of names) { + if (!filt[name]) { filt[name] = ko.observable(name); } + } + // Record the observables that affect this ActionGroup specifically + ag.affectedTableIds = names.map(name => ag.tableFilters![name]).filter(obs => obs); + } + + /** + * Helper function that returns true if any table touched by the ActionGroup + * is set to be visible. + */ + private _hasSelectedTable(ag: ActionGroupWithState): boolean { + if (!this._gristDoc) { return true; } + return ag.affectedTableIds!.some(tableId => tableId() === this._selectedTableId()); + } + + /** + * Return a koDom.show clause that activates when the named table is not + * filtered out. + */ + private _showForTable(tableName: string, ag?: ActionGroupWithState): boolean { + if (!ag) { return true; } + const obs = ag.tableFilters![tableName]; + return this._showAllTables() || !obs || obs() === this._selectedTableId(); + } + + private _buildLogDom() { + this._loadActionSummaries().catch((error) => gristNotify(`Action Log failed to load`)); + return dom('div.action_log', + dom('div.preference_item', + koForm.checkbox(this._showAllTables, + dom.testId('ActionLog_allTables'), + dom('span.preference_desc', 'All tables'))), + dom('div.action_log_load', + koDom.show(() => this._loading()), + 'Loading...'), + koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => { + const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : ""; + let desc = ag.desc || ""; + if (ag.actionSummary) { + desc = this.renderTabularDiffs(ag.actionSummary, desc, ag); + } + return dom('div.action_log_item', + koDom.cssClass(ag.state), + koDom.show(() => this._showAllTables() || this._hasSelectedTable(ag)), + dom('div.action_info', + dom('span.action_info_action_num', `#${ag.actionNum}`), + ag.user ? dom('span.action_info_user', + ag.user, + koDom.toggleClass('action_info_from_self', ag.fromSelf) + ) : '', + dom('span.action_info_timestamp', timestamp)), + dom('span.action_desc', desc) + ); + }) + ); + } + + /** + * Fetch summaries of recent actions (with summaries) from the server. + */ + private async _loadActionSummaries() { + if (this._loaded || !this._gristDoc) { return; } + this._loading(true); + const result = await this._gristDoc!.docComm.getActionSummaries(); + this._loading(false); + this._loaded = true; + // Add the actions to our action log. + result.forEach(item => this.pushAction(item)); + // Add any actions that came in while we were fetching. Unlikely, but + // perhaps possible? + const top = result[0] ? result[0].actionNum : 0; + for (const item of this._pending) { + if (item.actionNum > top) { this.pushAction(item); } + } + this._pending.length = 0; + } + + /** + * Prepare dom element(s) for a cell that has been created, destroyed, + * or modified. + * + * @param {CellDelta|string|null} cell - a structure with before and after values, + * or a plain string, or null + * + */ + private _renderCell(cell: CellDelta|string|null) { + // we'll show completely empty cells as "..." + if (cell === null) { + return "..."; + } + // strings are shown as themselves + if (typeof(cell) === 'string') { + return cell; + } + // by elimination, we have a TabularDiff.CellDelta with before and after values. + const [pre, post] = cell; + if (!pre && !post) { + // very boring before + after values :-) + return ""; + } else if (pre && !post) { + // this is a cell that was removed + return dom('span.action_log_cell_remove', pre[0]); + } else if (post && (pre === null || (pre[0] === null || pre[0] === ''))) { + // this is a cell that was added, or modified from a previously empty value + return dom('span.action_log_cell_add', post[0]); + } else if (pre && post) { + // a modified cell + return dom('div', + dom('span.action_log_cell_remove.action_log_cell_pre', pre[0]), + dom('span.action_log_cell_add', post[0])); + } + return JSON.stringify(cell); + } + + /** + * Choose a table name to show. For now, we show diffs of metadata tables also. + * For those tables, we show "_grist_Foo_bar" as "[Foo.bar]". + * @param {string} name - tableId of table + * @returns {string} a friendlier name for the table + */ + private _renderTableName(name: string): string { + if (name.indexOf('_grist_') !== 0) { + // Ordinary data table. Ideally, we would look up + // a friendly name from a raw data view - TODO. + return name; + } + const metaName = name.split('_grist_')[1].replace(/_/g, '.'); + return `[${metaName}]`; + } + + /** + * Show an ActionLog item when a column or table is renamed, added, or removed. + * Make sure the item is only shown when the affected table is not filtered out. + * + * @param scope: blank for tables, otherwise "." + * @param pair: the rename/addition/removal in LabelDelta format: [null, name1] + * for addition of name1, [name2, null] for removal of name2, [name1, name2] + * for a rename of name1 to name2. + * @return a filtered dom element. + */ + private _renderSchemaChange(scope: string, pair: LabelDelta, ag?: ActionGroupWithState) { + const [pre, post] = pair; + // ignore addition/removal of manualSort column + if ((pre || post) === 'manualSort') { return dom('div'); } + return dom('div.action_log_rename', + koDom.show(() => this._showForTable(post || defunctTableName(pre!), ag)), + (!post ? ["Remove ", scope, dom("span.action_log_rename_pre", pre)] : + (!pre ? ["Add ", scope, dom("span.action_log_rename_post", post)] : + ["Rename ", scope, dom("span.action_log_rename_pre", pre), + " to ", dom("span.action_log_rename_post", post)]))); + } + + /** + * Show any table additions/removals/renames. + */ + private _renderTableSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) { + return dom('div', + sum.tableRenames.map(pair => this._renderSchemaChange("", pair, ag))); + } + + /** + * Show any column additions/removals/renames. + */ + private _renderColumnSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) { + return dom('div', + Object.keys(sum.tableDeltas).filter(key => !key.startsWith('-')).map(key => + dom('div', + koDom.show(() => this._showForTable(key, ag)), + sum.tableDeltas[key].columnRenames.map(pair => + this._renderSchemaChange(key + ".", pair))))); + } + + /** + * Move cursor to show a given cell of a given table. Uses primary view of table. + */ + private async _selectCell(rowId: number, colId: string, tableId: string, actionNum: number) { + if (!this._gristDoc) { return; } + + // Find action in the stack. + const index = this._displayStack.peek().findIndex(a => a.actionNum === actionNum); + if (index < 0) { throw new Error(`Cannot find action ${actionNum} in the action log.`); } + + // Found the action. Now trace forward to find current tableId, colId, rowId. + for (let i = index; i >= 0; i--) { + const action = this._displayStack.at(i)!; + const sum = action.actionSummary; + + // Check if this table was renamed / removed. + const tableRename: LabelDelta|undefined = sum.tableRenames.find(r => r[0] === tableId); + if (tableRename) { + const newName = tableRename[1]; + if (!newName) { + // TODO - find a better way to send informative notifications. + gristNotify(`Table ${tableId} was subsequently removed in action #${action.actionNum}`); + return; + } + tableId = newName; + } + const td = sum.tableDeltas[tableId]; + if (!td) { continue; } + + // Check is this row was removed - if so there's no reason to go on. + if (td.removeRows.indexOf(rowId) >= 0) { + // TODO - find a better way to send informative notifications. + gristNotify(`This row was subsequently removed in action #${action.actionNum}`); + return; + } + + // Check if this column was renamed / added. + const columnRename: LabelDelta|undefined = td.columnRenames.find(r => r[0] === colId); + if (columnRename) { + const newName = columnRename[1]; + if (!newName) { + // TODO - find a better way to send informative notifications. + gristNotify(`Column ${colId} was subsequently removed in action #${action.actionNum}`); + return; + } + colId = newName; + } + } + + // Find the table model of interest. + const tableModel = this._gristDoc.getTableModel(tableId); + if (!tableModel) { return; } + + // Get its "primary" view. + const viewRow = tableModel.tableMetaRow.primaryView(); + const viewId = viewRow.getRowId(); + + // Switch to that view. + await this._gristDoc.openDocPage(viewId); + + // Now let's pick a reasonable section in that view. + const viewSection = viewRow.viewSections().peek().find((s: any) => s.table().tableId() === tableId); + if (!viewSection) { return; } + const sectionId = viewSection.getRowId(); + + // Within that section, find the column of interest if possible. + const fieldIndex = viewSection.viewFields().peek().findIndex((f: any) => f.colId.peek() === colId); + + // Finally, move cursor position to the section, column (if we found it), and row. + this._gristDoc.moveToCursorPos({rowId, sectionId, fieldIndex}); + } + +} diff --git a/app/client/components/Base.js b/app/client/components/Base.js new file mode 100644 index 00000000..1da2965f --- /dev/null +++ b/app/client/components/Base.js @@ -0,0 +1,94 @@ +/** + * This is the base class for components. The purpose is to abstract away several + * common idioms to make derived components simpler. + * + * Usage: + * function Component(gristDoc) { + * Base.call(this, gristDoc); + * ... + * } + * Base.setBaseFor(Component); + * + * To create an object: + * var obj = Component.create(constructor_args...); + */ + +/* global $ */ + +var dispose = require('../lib/dispose'); + +/** + * gristDoc may be null when there is no active document. + */ +function Base(gristDoc) { + this.gristDoc = gristDoc; + + this._debugName = this.constructor.name + '[' + Base._nextObjectId + ']'; + // TODO: devise a logging system that allows turning on/off different debug tags and levels. + //console.log(this._debugName, "Base constructor"); + + this._eventNamespace = '.Events_' + (Base._nextObjectId++); + this._eventSources = []; + + this.autoDisposeCallback(this.clearEvents); +} + +Base._nextObjectId = 1; + +/** + * Sets ctor to inherit prototype methods from Base. + * @param {function} ctor Constructor function which needs to inherit Base's prototype. + */ +Base.setBaseFor = function(ctor) { + ctor.prototype = Object.create(Base.prototype, { + constructor: { + value: ctor, + enumerable: false, + writable: true, + configurable: true + } + }); + dispose.makeDisposable(ctor); +}; + + +/** + * Subscribe to eventType on source, similarly to $(source).on(eventType, optSelector, method). + * In fact, this uses JQuery internally. The convenience is that it allows unsubscribing in bulk. + * Also, method is called with the context of `this`. + */ +Base.prototype.onEvent = function(source, eventType, optSelector, method) { + if (typeof optSelector != 'string') { + method = optSelector; + optSelector = null; + } + if (this._eventSources.indexOf(source) === -1) + this._eventSources.push(source); + + var self = this; + $(source).on(eventType + this._eventNamespace, optSelector, function(event_args) { + Array.prototype.unshift.call(arguments, this); // Unshift is generic enough for 'arguments'. + if (self._eventSources) + return method.apply(self, arguments); + }); +}; + +/** + * Unsubscribes this object from eventType on source, similarly to $(source).off(eventType). + */ +Base.prototype.clearEvent = function(source, eventType) { + $(source).off(eventType + this._eventNamespace); +}; + +/** + * Unsubscribes this object from all events that it subscribed to via onEvent(). + */ +Base.prototype.clearEvents = function() { + var sources = this._eventSources; + for (var i = 0; i < sources.length; i++) { + $(sources[i]).off(this._eventNamespace); + } + this._eventSources.length = 0; +}; + +module.exports = Base; diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js new file mode 100644 index 00000000..584dc446 --- /dev/null +++ b/app/client/components/BaseView.js @@ -0,0 +1,639 @@ +/* global window */ + +var _ = require('underscore'); +var ko = require('knockout'); +var moment = require('moment-timezone'); +var {getSelectionDesc} = require('app/common/DocActions'); +var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil'); +var gristTypes = require('app/common/gristTypes'); +var koUtil = require('../lib/koUtil'); +var tableUtil = require('../lib/tableUtil'); +var {DataRowModel} = require('../models/DataRowModel'); +var {DynamicQuerySet} = require('../models/QuerySet'); +var {SortFunc} = require('app/common/SortFunc'); +var rowset = require('../models/rowset'); +var Base = require('./Base'); +var {Cursor} = require('./Cursor'); +var FieldBuilder = require('../widgets/FieldBuilder'); +var commands = require('./commands'); +var LinkingState = require('./LinkingState'); +var BackboneEvents = require('backbone').Events; +const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters'); +const {reportError, UserError} = require('app/client/models/errors'); +const {urlState} = require('app/client/models/gristUrlState'); +const {SectionFilter} = require('app/client/models/SectionFilter'); +const {copyToClipboard} = require('app/client/lib/copyToClipboard'); +const {setTestState} = require('app/client/lib/testState'); +const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu'); + +/** + * BaseView forms the basis for ViewSection classes. + * @param {Object} viewSectionModel - The model for the viewSection represented. + * @param {Boolean} options.addNewRow - Whether to include an add row in the model. + */ +function BaseView(gristDoc, viewSectionModel, options) { + Base.call(this, gristDoc); + + this.options = options || {}; + this.viewSection = viewSectionModel; + this._name = this.viewSection.titleDef.peek(); + + //-------------------------------------------------- + // Observable models mapped to the document + + // Instantiate the models for the view metadata and for the data itself. + // The table should never change for a given view, so no need to watch the table() observable. + this.schemaModel = this.viewSection.table(); + + // Check if we are making a comparison with another document. + this.comparison = this.gristDoc.comparison; + if (this.comparison) { + const tableId = this.schemaModel.tableId(); + // TODO: make robust to name changes. + this.leftTableDelta = this.comparison.details.leftChanges.tableDeltas[tableId]; + this.rightTableDelta = this.comparison.details.rightChanges.tableDeltas[tableId]; + } else { + this.rightTableDelta = null; + this.leftTableDelta = null; + } + + // TODO: but accessing by tableId identifier may be problematic when the table is renamed. + this.tableModel = this.gristDoc.getTableModelMaybeWithDiff(this.schemaModel.tableId()); + + // We use a DynamicQuerySet as the underlying RowSource, with ColumnFilters applies on top of + // it. It filters based on section linking, re-querying as needed in case of onDemand tables. + this._queryRowSource = DynamicQuerySet.create(this, gristDoc.querySetManager, this.tableModel); + + // When we have a summary table, filter out rows corresponding to empty groups. + // (TODO this may be better implemented by deleting empty groups in the data engine.) + if (this.viewSection.table().summarySourceTable()) { + const groupGetter = this.tableModel.tableData.getRowPropFunc('group'); + this._mainRowSource = rowset.BaseFilteredRowSource.create(this, + rowId => !gristTypes.isEmptyList(groupGetter(rowId))); + this._mainRowSource.subscribeTo(this._queryRowSource); + } else { + this._mainRowSource = this._queryRowSource; + } + + if (this.comparison) { + // Assign extra row ids for any rows added in the remote (right) table. + // We flip their sign to make them as belonging to the remote table only. + // TODO: if we wanted to show rows removed in the local (left) table, we'd need to + // add those too, and come up with ids to give them. Without this, there's no + // way to render an update that was made remotely to a row that was removed locally. + const extraRowIds = (this.rightTableDelta && this.rightTableDelta.addRows || []) + .map(rowId => -rowId); + this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds); + } + + // Create a section filter and a filtered row source that subscribes to its changes. + // `sectionFilter` also provides an `addTemporaryRow()` to allow views to display newly inserted rows, + // and `setFilterOverride()` to allow controlling a filter from a column menu. + this._sectionFilter = SectionFilter.create(this, this.viewSection.viewFields, this.tableModel.tableData); + this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get()); + this._filteredRowSource.subscribeTo(this._mainRowSource); + this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener(filterFunc => { + this._filteredRowSource.updateFilter(filterFunc); + })); + + // Sorted collection of all rows to show in this view. + this.sortedRows = rowset.SortedRowSet.create(this, null); + + // Re-sort when sortSpec changes. + this.sortFunc = new SortFunc(new ClientColumnGetters(this.tableModel)); + this.autoDispose(this.viewSection.activeDisplaySortSpec.subscribeInit(function(spec) { + this.sortFunc.updateSpec(spec); + this.sortedRows.updateSort((rowId1, rowId2) => { + var value = nativeCompare(rowId1 === "new", rowId2 === "new"); + return value || this.sortFunc.compare(rowId1, rowId2); + }); + }, this)); + + // Here we are subscribed to the bulk of the data (main table, possibly filtered). + this.sortedRows.subscribeTo(this._filteredRowSource); + + // We create a special one-row RowSource for the "Add new" row, in case we need it. + this.newRowSource = rowset.RowSource.create(this); + this.newRowSource.getAllRows = function() { return ['new']; }; + + // This is the LazyArrayModel containing DataRowModels, for rendering, e.g. with scrolly. + this.viewData = this.autoDispose(this.tableModel.createLazyRowsModel(this.sortedRows)); + + // Floating row model that is not destroyed when the row is scrolled out of view. It must be + // assigned manually to a rowId. Additionally, we override the saving of field values with a + // custom method that handles better positioning of cursor on adding a new row. + this.editRowModel = this.autoDispose(this.tableModel.createFloatingRowModel()); + this.editRowModel._saveField = + (colName, value) => this._saveEditRowField(this.editRowModel, colName, value); + + // Reset heights of rows when there is an action that affects them. + this.listenTo(this.viewData, 'rowModelNotify', rowModels => this.onRowResize(rowModels)); + + this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize ); + + // Create a command group for keyboard shortcuts common to all views. + this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.hasFocus)); + + //-------------------------------------------------- + // Prepare logic for linking with other sections. + + // Linking state maintains .filterFunc and .cursorPos observables which we use for + // auto-scrolling and filtering. + this._linkingState = this.autoDispose(koUtil.computedBuilder(() => { + let v = this.viewSection; + let src = v.linkSrcSection(); + const filterByAllShown = v.optionsObj.prop('filterByAllShown'); + return src.getRowId() ? + LinkingState.create.bind(LinkingState, this.gristDoc, + src, v.linkSrcCol().colId(), v, v.linkTargetCol().colId(), filterByAllShown()) : + null; + })); + + this._linkingFilter = this.autoDispose(ko.computed(() => { + const linking = this._linkingState(); + return linking && linking.filterColValues ? linking.filterColValues() : {}; + })); + + // A computed for the rowId of the row selected by section linking. + this.linkedRowId = this.autoDispose(ko.computed(() => { + let linking = this._linkingState(); + return linking && linking.cursorPos ? linking.cursorPos() : null; + }).extend({deferred: true})); + + // Update the cursor whenever linkedRowId() changes. + this.autoDispose(this.linkedRowId.subscribe(rowId => this.setCursorPos({rowId}))); + + // Indicated whether editing the section should be disabled given the current linking state. + this.disableEditing = this.autoDispose(ko.computed(() => { + const linking = this._linkingState(); + return linking && linking.disableEditing(); + })); + + this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow && + !this.viewSection.disableAddRemoveRows() && !this.disableEditing())); + + // Hide the add row if editing is disabled via filter linking. + this.autoDispose(this.enableAddRow.subscribeInit(_enableAddRow => { + if (_enableAddRow) { + this.sortedRows.subscribeTo(this.newRowSource); + } else { + this.sortedRows.unsubscribeFrom(this.newRowSource); + } + })); + + //-------------------------------------------------- + // Observables local to this view + this._isLoading = ko.observable(true); + this._pendingCursorPos = this.viewSection.lastCursorPos; + + // Initialize the cursor with the previous cursor position indicies, if they exist. + console.log("%s BaseView viewSection %s (%s) lastCursorPos %s", this._debugName, this.viewSection.getRowId(), + this.viewSection.table().tableId(), JSON.stringify(this.viewSection.lastCursorPos)); + this.cursor = this.autoDispose(Cursor.create(null, this, this.viewSection.lastCursorPos)); + + this.currentColumn = this.autoDispose(ko.pureComputed(() => + this.viewSection.viewFields().at(this.cursor.fieldIndex()).column() + ).extend({rateLimit: 0})); // TODO Test this without the rateLimit + + this.currentEditingColumnIndex = ko.observable(-1); + + // A koArray of FieldBuilder objects, one for each view-section field. + this.fieldBuilders = this.autoDispose( + FieldBuilder.createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor) + ); + + // An observable evaluating to the FieldBuilder for the field where the cursor is. + this.activeFieldBuilder = this.autoDispose(ko.pureComputed(() => + this.fieldBuilders.at(this.cursor.fieldIndex()) + )); + + // Observable for whether the data in this view is truncated, i.e. not all rows are included + // (this can only be true for on-demand tables). + this.isTruncated = ko.observable(false); + + // This computed's purpose is the side-effect of calling makeQuery() initially and when any + // dependency changes. + this.autoDispose(ko.computed(() => { + this._isLoading(true); + this._queryRowSource.makeQuery(this._linkingFilter(), (err) => { + if (this.isDisposed()) { return; } + if (err) { window.gristNotify(`Query error: ${err.message}`); } + this.onTableLoaded(); + }); + })); + + // Reset cursor to the first row when filtering changes. + this.autoDispose(this._linkingFilter.subscribe((x) => this.setCursorPos({rowIndex: 0}))); + + // When sorting changes, reset the cursor to the first row. (The alternative of moving the + // cursor to stay at the same record is sometimes better, but sometimes more annoying.) + this.autoDispose(this.viewSection.activeSortSpec.subscribe(() => this.setCursorPos({rowIndex: 0}))); + + this.copySelection = ko.observable(null); +} +Base.setBaseFor(BaseView); +_.extend(Base.prototype, BackboneEvents); + +/** + * These commands are common to GridView and DetailView. + */ +BaseView.commonCommands = { + input: function(input) { this.activateEditorAtCursor(input); }, + editField: function() { this.activateEditorAtCursor(); }, + + insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); }, + insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex() + 1); }, + + insertCurrentDate: function() { this.insertCurrentDate(false); }, + insertCurrentDateTime: function() { this.insertCurrentDate(true); }, + + copyLink: function() { this.copyLink().catch(reportError); }, +}; + +/** + * Sets the cursor to the given position, deferring if necessary until the current query finishes + * loading. + */ +BaseView.prototype.setCursorPos = function(cursorPos) { + if (!this._isLoading.peek()) { + this.cursor.setCursorPos(cursorPos); + } else { + // This is the first step; the second happens in onTableLoaded. + this._pendingCursorPos = cursorPos; + this.cursor.setLive(false); + } +}; + +/** + * Returns a promise that's resolved when the query being loaded finishes loading. + * If no query is being loaded, it will resolve immediately. + */ +BaseView.prototype.getLoadingDonePromise = function() { + return waitObs(this._isLoading, (value) => !value); +}; + +/** + * Start editing the selected cell. + * @param {String} input: If given, initialize the editor with the given input (rather than the + * original content of the cell). + */ +BaseView.prototype.activateEditorAtCursor = function(input) { + var builder = this.activeFieldBuilder(); + if (builder.isEditorActive()) { + return; + } + var rowId = this.viewData.getRowId(this.cursor.rowIndex()); + // LazyArrayModel row model which is also used to build the cell dom. Needed since + // it may be used as a key to retrieve the cell dom, which is useful for editor placement. + var lazyRow = this.getRenderedRowModel(rowId); + if (builder.field.disableEditData() || this.gristDoc.isReadonly.get()) { + builder.flashCursorReadOnly(lazyRow); + } else { + if (!lazyRow) { + // TODO scroll into view. For now, just don't activate the editor. + return; + } + this.editRowModel.assign(rowId); + builder.buildEditorDom(this.editRowModel, lazyRow, { 'init': input }); + } +}; + +// Copy an anchor link for the current row to the clipboard. +BaseView.prototype.copyLink = async function() { + const rowId = this.viewData.getRowId(this.cursor.rowIndex()); + const colRef = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].colRef(); + const sectionId = this.viewSection.getRowId(); + try { + const link = urlState().makeUrl({ hash: { sectionId, rowId, colRef } }); + await copyToClipboard(link); + setTestState({clipboard: link}); + reportError(new UserError('Link copied to clipboard', {key: 'clipboard'})); + } catch (e) { + throw new Error('cannot copy to clipboard'); + } +}; + +/** + * Insert a new row immediately before the row at the given index if given an Integer. Otherwise + * insert a new row at the end. + */ +BaseView.prototype.insertRow = function(index) { + if (this.viewSection.disableAddRemoveRows() || this.disableEditing()) { + return; + } + var rowId = this.viewData.getRowId(index); + var insertPos = Number.isInteger(rowId) ? + this.tableModel.tableData.getValue(rowId, 'manualSort') : null; + + return this.sendTableAction(['AddRecord', null, { 'manualSort': insertPos }]) + .then(rowId => { + if (!this.isDisposed()) { + this._sectionFilter.addTemporaryRow(rowId); + this.setCursorPos({rowId}); + } + return rowId; + }); +}; + +/** + * Given a 2-d paste column-oriented paste data and target cols, transform the data to omit + * fields that shouldn't be pasted over and extract rich paste data if available. + * @param {Array>} data - Column-oriented 2-d array of either + * plain strings or rich paste data returned by `tableUtil.parsePasteHtml` with `displayValue` + * and, optionally, `colType` and `rawValue` attributes. + * @param {Array} cols - Array of target column objects + * @returns {Object} - Object mapping colId to array of column values, suitable for use in Bulk + * actions. + */ +BaseView.prototype._parsePasteForView = function(data, cols) { + let updateCols = cols.map(col => { + if (col && !col.isRealFormula() && !col.disableEditData()) { + return col; + } else { + return null; // Don't include formulas and missing columns + } + }); + let updateColIds = updateCols.map(c => c && c.colId()); + let updateColTypes = updateCols.map(c => c && c.type()); + + let richData = data; + + if (data.length > 0 && data[0].length > 0 && + _.isObject(data[0][0]) && data[0][0].hasOwnProperty('displayValue')) { + richData = data.map((col, idx) => { + if (col[0].colType === updateColTypes[idx]) { + return col.map(v => v.hasOwnProperty('rawValue') ? v.rawValue : v.displayValue); + } else { + return col.map(v => v.displayValue); + } + }); + } + + return _.omit(_.object(updateColIds, richData), null); +}; + +BaseView.prototype._getDefaultColValues = function() { + const filterValues = this._linkingFilter.peek(); + return _.mapObject(_.pick(filterValues, v => (v.length > 0)), v => v[0]); +}; + +/** + * Enhances [Bulk]AddRecord actions to include the default values determined by the current + * section-linking filter. + */ +BaseView.prototype._enhanceAction = function(action) { + if (action[0] === 'AddRecord' || action[0] === 'BulkAddRecord') { + let colValues = this._getDefaultColValues(); + let rowIds = action[1]; + if (action[0] === 'BulkAddRecord') { + colValues = _.mapObject(colValues, v => rowIds.map(() => v)); + } + Object.assign(colValues, action[2]); + return [action[0], rowIds, colValues]; + } else { + return action; + } +}; + +/** + * Enhances a list of table actions and turns them from implicit-table actions into + * proper actions. + */ +BaseView.prototype.prepTableActions = function(actions) { + actions = actions.map(a => this._enhanceAction(a)); + actions.forEach(action_ => { + action_.splice(1, 0, this.tableModel.tableData.tableId); + }); + return actions; +}; + +/** + * Shortcut for `.tableModel.tableData.sendTableActions`, which also sets default values + * determined by the current section-linking filter, if any. + */ +BaseView.prototype.sendTableActions = function(actions, optDesc) { + return this.tableModel.sendTableActions(actions.map(a => this._enhanceAction(a)), optDesc); +}; + + +/** + * Shortcut for `.tableModel.tableData.sendTableAction`, which also sets default values + * determined by the current section-linking filter, if any. + */ +BaseView.prototype.sendTableAction = function(action, optDesc) { + return action ? this.tableModel.sendTableAction(this._enhanceAction(action), optDesc) : null; +}; + + +/** + * Inserts the current date/time into the selected cell if the cell is of a compatible type + * (Text/Date/DateTime/Any). + * @param {Boolean} withTime: Whether to include the time in addition to the date. This is ignored + * for Date columns (assumed false) and for DateTime (assumed true). + */ +BaseView.prototype.insertCurrentDate = function(withTime) { + let column = this.currentColumn(); + if (column.isRealFormula()) { + // Ignore the shortcut when in a formula column. + return; + } + let type = column.pureType(); + let value, now = Date.now(); + const docTimezone = this.gristDoc.docInfo.timezone.peek(); + if (type === 'Text' || type === 'Any') { + // Use document timezone. Don't forget to use uppercase HH for 24-hour time. + value = moment.tz(now, docTimezone).format('YYYY-MM-DD' + (withTime ? ' HH:mm:ss' : '')); + } else if (type === 'Date') { + // Get UTC midnight for the current date (as seen in docTimezone). This is a bit confusing. If + // it's "2019-11-14 23:30 -05:00", then it's "2019-11-15 04:30" in UTC. Since we measure time + // from Epoch UTC, we want the UTC time to have the correct date, so need to add the offset + // (-05:00) to get "2019-11-14 23:30" in UTC, and then round down to midnight. + const offsetMinutes = moment.tz(now, docTimezone).utcOffset(); + value = roundDownToMultiple(now / 1000 + offsetMinutes * 60, 24*3600); + } else if (type === 'DateTime') { + value = now / 1000; + } else { + // Ignore the shortcut when in a column of an inappropriate type. + return; + } + var rowId = this.viewData.getRowId(this.cursor.rowIndex()); + this.editRowModel.assign(rowId); + this.editRowModel[column.colId()].setAndSave(value); +}; + + +/** + * Override the saving of field values to add some extra processing: + * - If a new row is saved, then we may need to adjust the row where the cursor is. + * - We add the edited or added row to ensure it's displayed regardless of current columnFilters. + * - We change the main view's row observables to see the new value immediately. + * TODO: When saving a formula in the addRow, the cursor moves down instead of staying in place. + * To fix that behavior, propose to factor out the `isAddRow` overrides from here + * into a `setNewRowColValues` on the editRowModel and have `FieldBuilder._saveEdit` call + * that instead of `updateColValues`. + */ +BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) { + if (editRowModel._isAddRow.peek()) { + this.cursor.setLive(false); + const colValues = this._getDefaultColValues(); + colValues[colName] = value; + + return editRowModel.updateColValues(colValues) + // Once we know the new row's rowId, add it to column filters to make sure it's displayed. + .then(rowId => { + if (!this.isDisposed()) { + this._sectionFilter.addTemporaryRow(rowId); + this.setCursorPos({rowId}); + } + return rowId; + }) + .finally(() => !this.isDisposed() && this.cursor.setLive(true)); + } else { + var rowId = editRowModel.getRowId(); + // We are editing the floating "edit" rowModel, but to ensure that we see data in the main view + // (when the editor closes), we immediately update the main view's rowModel, if such exists. + var mainRowModel = this.getRenderedRowModel(rowId); + if (mainRowModel) { + mainRowModel[colName](value); + } + const ret = DataRowModel.prototype._saveField.call(editRowModel, colName, value) + // Display this rowId, even if it doesn't match the filter + .then((result) => { + if (!this.isDisposed()) { + this._sectionFilter.addTemporaryRow(rowId); + } + return result; + }) + .finally(() => !this.isDisposed() && mainRowModel && mainRowModel._assignColumn(colName)); + return this.viewSection.isSorted() ? ret : null; + // Do not return the saveField call in the case that the column is unsorted: in this case, + // we assumes optimistically that the action is successful and browser events can + // continue being processed immediately without waiting. + // When sorted, we wait on the saveField call so we may determine where the row ends + // up for cursor movement purposes. + } +}; + +/** + * Uses the current cursor selection to return a rich paste object with a reference to the data, + * and the selection ranges. See CopySelection.js + * + * @returns {pasteObj} - Paste object + */ +BaseView.prototype.copy = function(selection) { + this.copySelection(selection); + + return { + data: this.tableModel.tableData, + selection: selection + }; +}; + +/** + * Uses the current cursor selection to return a rich paste object with a reference to the data, + * the selection ranges and a callback that when called performs all of the actions needed for a cut. + * + * @returns {pasteObj} - Paste object + */ +BaseView.prototype.cut = function(selection) { + this.copySelection(selection); + + return { + data: this.tableModel.tableData, + selection: selection, + cutCallback: () => tableUtil.makeDeleteAction(selection) + }; +}; + +/** + * Helper to send paste actions from the cutCallback and a list of paste actions. + */ +BaseView.prototype.sendPasteActions = function(cutCallback, actions) { + let cutAction = null; + // If this is a cut -> paste, add the cut action and a description. + if (cutCallback) { + cutAction = cutCallback(); + // If the cut occurs on an edit restricted cell, there may be no cut action. + if (cutAction) { actions.unshift(cutAction); } + } + return this.gristDoc.docData.sendActions(actions, + this._getPasteDesc(actions[actions.length - 1], cutAction)); +}; + +/** + * Returns a string which describes a cut/copy action. + */ +BaseView.prototype._getPasteDesc = function(pasteAction, optCutAction) { + if (optCutAction) { + return `Moved ${getSelectionDesc(optCutAction, true)} to ` + + `${getSelectionDesc(pasteAction, true)}.`; + } else { + return `Pasted data to ${getSelectionDesc(pasteAction, true)}.`; + } +}; + +BaseView.prototype.buildDom = function() { + throw new Error("Not Implemented"); +}; + +/** + * Called by ViewLayout to return view-specific controls to add into its ViewSection's title bar. + * By default builds nothing. Derived views may override. + */ +BaseView.prototype.buildTitleControls = function() { + return null; +}; + +/** + * Called when table data gets loaded (if already loaded, then called immediately after the + * constructor). Derived views may override. + */ +BaseView.prototype.onTableLoaded = function() { + // Complete the setting of a pending cursor position (see setCursorPos() for the first half). + if (this._pendingCursorPos) { + this.cursor.setCursorPos(this._pendingCursorPos); + this._pendingCursorPos = null; + } + this._isLoading(false); + this.isTruncated(this._queryRowSource.isTruncated); + this.cursor.setLive(true); +}; + +/** + * Called when view gets resized. Derived views may override. + */ +BaseView.prototype.onResize = function() { +}; + +/** + * Called when rows have changed and may potentially need resizing. Derived views may override. + * @param {Array} rowModels: Array of row models whose size may have changed. + */ +BaseView.prototype.onRowResize = function(rowModels) { +}; + +/** + * Called to obtain the rowModel for the given rowId. Returns a rowModel if it belongs to the + * section and is rendered, otherwise returns null. + * Useful to tie a rendered row to the row being edited. Derived views may override. + */ +BaseView.prototype.getRenderedRowModel = function(rowId) { + return this.viewData.getRowModel(rowId); +}; + +/** + * Returns the index of the last non-AddNew row in the grid. + */ +BaseView.prototype.getLastDataRowIndex = function() { + let last = this.viewData.peekLength - 1; + return (last >= 0 && this.viewData.getRowId(last) === 'new') ? last - 1 : last; +}; + +/** + * Creates and opens ColumnFilterMenu for a given field, and returns its PopupControl. + */ +BaseView.prototype.createFilterMenu = function(openCtl, field) { + return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData); +}; + +module.exports = BaseView; diff --git a/app/client/components/ChartView.css b/app/client/components/ChartView.css new file mode 100644 index 00000000..b80dce14 --- /dev/null +++ b/app/client/components/ChartView.css @@ -0,0 +1,6 @@ +.chart_container { + overflow: hidden; + position: absolute; + height: 100%; + width: 100%; +} diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts new file mode 100644 index 00000000..66056407 --- /dev/null +++ b/app/client/components/ChartView.ts @@ -0,0 +1,508 @@ +import * as BaseView from 'app/client/components/BaseView'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {sortByXValues} from 'app/client/lib/chartUtil'; +import {Delay} from 'app/client/lib/Delay'; +import {Disposable} from 'app/client/lib/dispose'; +import {fromKoSave} from 'app/client/lib/fromKoSave'; +import {loadPlotly, PlotlyType} from 'app/client/lib/imports'; +import * as DataTableModel from 'app/client/models/DataTableModel'; +import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {KoSaveableObservable, ObjObservable} from 'app/client/models/modelUtil'; +import {SortedRowSet} from 'app/client/models/rowset'; +import {cssRow} from 'app/client/ui/RightPanel'; +import {squareCheckbox} from 'app/client/ui2018/checkbox'; +import {colors, vars} from 'app/client/ui2018/cssVars'; +import {linkSelect, select} from 'app/client/ui2018/menus'; +import {nativeCompare} from 'app/common/gutil'; +import {Events as BackboneEvents} from 'backbone'; +import {dom, DomElementArg, makeTestId, styled} from 'grainjs'; +import * as ko from 'knockout'; +import debounce = require('lodash/debounce'); +import defaults = require('lodash/defaults'); +import defaultsDeep = require('lodash/defaultsDeep'); +import {Config, Data, Datum, ErrorBar, Layout, LayoutAxis, Margin} from 'plotly.js'; + +let Plotly: PlotlyType; + +// When charting multiple series based on user data, limit the number of series given to plotly. +const MAX_SERIES_IN_CHART = 100; + +const testId = makeTestId('test-chart-'); + +interface ChartOptions { + multiseries?: boolean; + lineConnectGaps?: boolean; + lineMarkers?: boolean; + invertYAxis?: boolean; + logYAxis?: boolean; + // If "symmetric", one series after each Y series gives the length of the error bars around it. If + // "separate", two series after each Y series give the length of the error bars above and below it. + errorBars?: 'symmetric' | 'separate'; +} + +// tslint:disable:no-console + +// We use plotly's Datum to describe the type of values in cells. Cells may not match this +// perfectly, but it's helpful for type-checking anyway. +type RowPropGetter = (rowId: number) => Datum; + +// We convert Grist data to a list of Series first, from which we then construct Plotly traces. +interface Series { + label: string; // Corresponds to the column name. + group?: Datum; // The group value, when grouped. + values: Datum[]; +} + +function getSeriesName(series: Series, haveMultiple: boolean) { + if (!series.group) { + return series.label; + } else if (haveMultiple) { + return `${series.group} \u2022 ${series.label}`; // the unicode character is "black circle" + } else { + return String(series.group); + } +} + + +// The output of a ChartFunc. Normally it just returns one or more Data[] series, but sometimes it +// includes layout information: e.g. a "Scatter Plot" returns a Layout with axis labels. +interface PlotData { + data: Data[]; + layout?: Partial; + config?: Partial; +} + +// Convert a list of Series into a set of Plotly traces. +type ChartFunc = (series: Series[], options: ChartOptions) => PlotData; + + +// Helper for converting numeric Date/DateTime values (seconds since Epoch) to JS Date objects for +// use with plotly. +function dateGetter(getter: RowPropGetter): RowPropGetter { + return (r: number) => { + // 0's will turn into nulls, and non-numbers will turn into NaNs and then nulls. This prevents + // Plotly from including 1970-01-01 onto X axis, which usually makes the plot useless. + const val = (getter(r) as number) * 1000; + // Plotly recommends using strings for dates rather than Date objects or timestamps. They are + // interpreted more consistently. See https://github.com/plotly/plotly.js/issues/1532#issuecomment-290420534. + return val ? new Date(val).toISOString() : null; + }; +} + +/** + * ChartView component displays created charts. + */ +export class ChartView extends Disposable { + public viewPane: Element; + + // These elements are defined in BaseView, from which we inherit with some hackery. + protected viewSection: ViewSectionRec; + protected sortedRows: SortedRowSet; + protected tableModel: DataTableModel; + + private _chartType: ko.Observable; + private _options: ObjObservable; + private _chartDom: HTMLElement; + private _update: () => void; + + public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { + BaseView.call(this as any, gristDoc, viewSectionModel); + + this._chartDom = this.autoDispose(this.buildDom()); + + // Note that .viewPane is used by ViewLayout to insert the actual DOM into the document. + this.viewPane = this._chartDom; + + // Resize if the window resizes since that can change the layout leaf size. + // TODO: Belongs into ViewLayout which already does BaseView.onResize for side-pane open/close. + const resizeChart = this.autoDispose(Delay.untilAnimationFrame(this._resizeChart, this)); + window.addEventListener('resize', resizeChart); + this.autoDisposeCallback(() => window.removeEventListener('resize', resizeChart)); + + this._chartType = this.viewSection.chartTypeDef; + this._options = this.viewSection.optionsObj; + + this._update = debounce(() => this._updateView(), 0); + + this.autoDispose(this._chartType.subscribe(this._update)); + this.autoDispose(this._options.subscribe(this._update)); + this.autoDispose(this.viewSection.viewFields().subscribe(this._update)); + this.listenTo(this.sortedRows, 'rowNotify', this._update); + this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update)); + } + + protected onTableLoaded() { + (BaseView.prototype as any).onTableLoaded.call(this); + this._update(); + } + + protected onResize() { + this._resizeChart(); + } + + protected buildDom() { + return dom('div.chart_container', testId('container')); + } + + private listenTo(...args: any[]): void { /* replaced by Backbone */ } + + private async _updateView() { + if (this.isDisposed()) { return; } + + const chartFunc = chartTypes[this._chartType()]; + if (typeof chartFunc !== 'function') { + console.warn("Unknown trace type %s", this._chartType()); + return; + } + + const fields: ViewFieldRec[] = this.viewSection.viewFields().all(); + const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[]; + const series: Series[] = fields.map((field) => { + // Use the colId of the displayCol, which may be different in case of Reference columns. + const colId: string = field.displayColModel.peek().colId.peek(); + const getter = this.tableModel.tableData.getRowPropFunc(colId) as RowPropGetter; + const pureType = field.displayColModel().pureType(); + const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter; + return { + label: field.label(), + values: rowIds.map(fullGetter), + }; + }); + + const options: ChartOptions = this._options.peek() || {}; + let plotData: PlotData = {data: []}; + + if (!options.multiseries) { + plotData = chartFunc(series, options); + } else if (series.length > 1) { + // We need to group all series by the first column. + const nseries = groupSeries(series[0].values, series.slice(1)); + + // This will be in the order in which nseries Map was created; concat() flattens the arrays. + for (const gSeries of nseries.values()) { + const part = chartFunc(gSeries, options); + part.data = plotData.data.concat(part.data); + plotData = part; + } + } + + Plotly = Plotly || await loadPlotly(); + + // Loading plotly is asynchronous and it may happen that the chart view had been disposed in the + // meantime and cause error later. So let's check again. + if (this.isDisposed()) { return; } + + const layout: Partial = defaultsDeep(plotData.layout, getPlotlyLayout(options)); + const config: Partial = {...plotData.config, displayModeBar: false}; + // react() can be used in place of newPlot(), and is faster when updating an existing plot. + await Plotly.react(this._chartDom, plotData.data, layout, config); + this._resizeChart(); + } + + private _resizeChart() { + if (this.isDisposed() || !Plotly) { return; } + Plotly.Plots.resize(this._chartDom); + } +} + +/** + * Group the given array of series by a column of group values. The groupColumn and each of the + * series should be arrays of the same length. + * + * For example, if groupColumn has CompanyID, and valueSeries contains [Date, Employees, Revenues] + * (each an array of values), then returns a map mapping each CompanyID to the array [Date, + * Employees, Revenue], each value of which is itself an array of values for that CompanyID. + */ +function groupSeries(groupColumn: T[], valueSeries: Series[]): Map { + const nseries = new Map(); + + // Limit the number if group values so as to limit the total number of series we pass into + // Plotly. Too many series are impossible to make sense of anyway, and can hang the browser. + // TODO: When not all data is shown, we should probably show some indicator, similar to when + // OnDemand data is truncated. + const maxGroups = Math.floor(MAX_SERIES_IN_CHART / valueSeries.length); + const groupValues: T[] = [...new Set(groupColumn)].sort().slice(0, maxGroups); + + // Set up empty lists for each group. + for (const group of groupValues) { + nseries.set(group, valueSeries.map((s: Series) => ({ + label: s.label, + group, + values: [] + }))); + } + + // Now fill up the lists. + for (let row = 0; row < groupColumn.length; row++) { + const group = groupColumn[row]; + const series: Series[]|undefined = nseries.get(group); + if (series) { + for (let i = 0; i < valueSeries.length; i++) { + series[i].values.push(valueSeries[i].values[row]); + } + } + } + return nseries; +} + +// If errorBars are requested, removes error bar series from the 'series' list, adding instead a +// mapping from each main Y series to the corresponding plotly ErrorBar object. +function extractErrorBars(series: Series[], options: ChartOptions): Map { + const result = new Map(); + if (options.errorBars) { + // We assume that series is of the form [X, Y1, Y1-bar, Y2, Y2-bar, ...] (if "symmetric") or + // [X, Y1, Y1-below, Y1-above, Y2, Y2-below, Y2-above, ...] (if "separate"). + for (let i = 1; i < series.length; i++) { + result.set(series[i], { + type: 'data', + symmetric: (options.errorBars === 'symmetric'), + array: series[i + 1] && series[i + 1].values, + arrayminus: (options.errorBars === 'separate' ? series[i + 2] && series[i + 2].values : undefined), + thickness: 1, + width: 3, + }); + series.splice(i + 1, (options.errorBars === 'symmetric' ? 1 : 2)); + } + } + return result; +} + +// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. +defaults(ChartView.prototype, BaseView.prototype); +Object.assign(ChartView.prototype, BackboneEvents); + +function getPlotlyLayout(options: ChartOptions): Partial { + // Note that each call to getPlotlyLayout() creates a new layout object. We are intentionally + // avoiding reuse because Plotly caches too many layout calculations when the object is reused. + const yaxis: Partial = {}; + if (options.logYAxis) { yaxis.type = 'log'; } + if (options.invertYAxis) { yaxis.autorange = 'reversed'; } + return { + // Margins include labels, titles, legend, and may get auto-expanded beyond this. + margin: { + l: 50, + r: 50, + b: 40, // Space below chart which includes x-axis labels + t: 30, // Space above the chart (doesn't include any text) + pad: 4 + } as Margin, + legend: { + // Translucent background, so chart data is still visible if legend overlaps it. + bgcolor: "#FFFFFF80", + }, + yaxis, + }; +} + +/** + * Build the DOM for side-pane configuration options for a Chart section. + */ +export function buildChartConfigDom(section: ViewSectionRec) { + if (section.parentKey() !== 'chart') { return null; } + const optionsObj = section.optionsObj; + return [ + cssRow( + select(fromKoSave(section.chartTypeDef), [ + {value: 'bar', label: 'Bar Chart', icon: 'ChartBar' }, + {value: 'pie', label: 'Pie Chart', icon: 'ChartPie' }, + {value: 'area', label: 'Area Chart', icon: 'ChartArea' }, + {value: 'line', label: 'Line Chart', icon: 'ChartLine' }, + {value: 'scatter', label: 'Scatter Plot', icon: 'ChartLine' }, + {value: 'kaplan_meier', label: 'Kaplan-Meier Plot', icon: 'ChartKaplan'}, + ]), + testId("type"), + ), + dom.maybe((use) => use(section.chartTypeDef) !== 'pie', () => [ + // These options don't make much sense for a pie chart. + cssCheckboxRow('Group by first column', optionsObj.prop('multiseries'), testId('multiseries')), + cssCheckboxRow('Invert Y-axis', optionsObj.prop('invertYAxis')), + cssCheckboxRow('Log scale Y-axis', optionsObj.prop('logYAxis')), + ]), + dom.maybe((use) => use(section.chartTypeDef) === 'line', () => [ + cssCheckboxRow('Connect gaps', optionsObj.prop('lineConnectGaps')), + cssCheckboxRow('Show markers', optionsObj.prop('lineMarkers')), + ]), + dom.maybe((use) => ['line', 'bar'].includes(use(section.chartTypeDef)), () => [ + cssRow(cssLabel('Error bars'), + dom('div', linkSelect(fromKoSave(optionsObj.prop('errorBars')), [ + {value: '', label: 'None'}, + {value: 'symmetric', label: 'Symmetric'}, + {value: 'separate', label: 'Above+Below'}, + ], {defaultLabel: 'None'})), + testId('error-bars'), + ), + dom.domComputed(optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) => + value === 'symmetric' ? cssRowHelp('Each Y series is followed by a series for the length of error bars.') : + value === 'separate' ? cssRowHelp('Each Y series is followed by two series, for top and bottom error bars.') : + null + ), + ]), + ]; +} + +function cssCheckboxRow(label: string, value: KoSaveableObservable, ...args: DomElementArg[]) { + return dom('label', cssRow.cls(''), + cssLabel(label), + squareCheckbox(fromKoSave(value), ...args), + ); +} + +function basicPlot(series: Series[], options: ChartOptions, dataOptions: Partial): PlotData { + trimNonNumericData(series); + const errorBars = extractErrorBars(series, options); + return { + data: series.slice(1).map((line: Series): Data => ({ + name: getSeriesName(line, series.length > 2), + x: series[0].values, + y: line.values, + error_y: errorBars.get(line), + ...dataOptions, + })), + layout: { + xaxis: series.length > 0 ? {title: series[0].label} : {}, + // Include yaxis title for a single y-value series only (2 series total); + // If there are fewer than 2 total series, there is no y-series to display. + // If there are multiple y-series, a legend will be included instead, and the yaxis title + // is less meaningful, so omit it. + yaxis: series.length === 2 ? {title: series[1].label} : {}, + }, + }; +} + +// Most chart types take a list of series and then use the first series for the X-axis, and each +// subsequent series for their Y-axis values, allowing for multiple lines on the same plot. +// Each series should have the form {label, values}. +export const chartTypes: {[name: string]: ChartFunc} = { + // TODO There is a lot of code duplication across chart types. Some refactoring is in order. + bar(series: Series[], options: ChartOptions): PlotData { + return basicPlot(series, options, {type: 'bar'}); + }, + line(series: Series[], options: ChartOptions): PlotData { + sortByXValues(series); + return basicPlot(series, options, { + type: 'scatter', + connectgaps: options.lineConnectGaps, + mode: options.lineMarkers ? 'lines+markers' : 'lines', + }); + }, + area(series: Series[], options: ChartOptions): PlotData { + sortByXValues(series); + return basicPlot(series, options, { + type: 'scatter', + fill: 'tozeroy', + line: {shape: 'spline'}, + }); + }, + scatter(series: Series[], options: ChartOptions): PlotData { + return basicPlot(series.slice(1), options, { + type: 'scatter', + mode: 'text+markers', + text: series[0].values as string[], + textposition: "bottom center", + }); + }, + + pie(series: Series[]): PlotData { + let line: Series; + if (series.length === 0) { + return {data: []}; + } + if (series.length > 1) { + trimNonNumericData(series); + line = series[1]; + } else { + // When there is only one series of labels, simply count their occurrences. + line = {label: 'Count', values: series[0].values.map(() => 1)}; + } + return { + data: [{ + type: 'pie', + name: getSeriesName(line, false), + // nulls cause JS errors when pie charts resize, so replace with blanks. + // (a falsy value would cause plotly to show its index, like "2" which is more confusing). + labels: series[0].values.map(v => (v == null || v === "") ? "-" : v), + values: line.values, + }] + }; + }, + + kaplan_meier(series: Series[]): PlotData { + // For this plot, the first series names the category of each point, and the second the + // survival time for that point. We turn that into as many series as there are categories. + if (series.length < 2) { return {data: []}; } + const newSeries = groupIntoSeries(series[0].values, series[1].values); + return { + data: newSeries.map((line: Series): Data => { + const points = kaplanMeierPlot(line.values as number[]); + return { + type: 'scatter', + mode: 'lines', + line: {shape: 'hv'}, + name: getSeriesName(line, false), + x: points.map(p => p.x), + y: points.map(p => p.y), + } as Data; + }) + }; + }, +}; + + +/** + * Assumes a list of series of the form [xValues, yValues1, yValues2, ...]. Remove from all series + * those points for which all of the y-values are non-numeric (e.g. null or a string). + */ +function trimNonNumericData(series: Series[]): void { + const values = series.slice(1).map((s) => s.values); + for (const s of series) { + s.values = s.values.filter((_, i) => values.some(v => typeof v[i] === 'number')); + } +} + +// Given two parallel arrays, returns an array of series of the form +// {label: category, values: array-of-values} +function groupIntoSeries(categoryList: Datum[], valueList: Datum[]): Series[] { + const groups = new Map(); + for (const [i, cat] of categoryList.entries()) { + if (!groups.has(cat)) { groups.set(cat, []); } + groups.get(cat).push(valueList[i]); + } + return Array.from(groups, ([label, values]) => ({label, values})); +} + +// Given a list of survivalValues, returns a list of {x, y} pairs for the kaplanMeier plot. +function kaplanMeierPlot(survivalValues: number[]): Array<{x: number, y: number}> { + // First get a distribution of survivalValue -> count. + const dist = new Map(); + for (const v of survivalValues) { + dist.set(v, (dist.get(v) || 0) + 1); + } + + // Sort the distinct values. + const distinctValues = Array.from(dist.keys()); + distinctValues.sort(nativeCompare); + + // Now generate plot values, with 'x' for survivalValue and 'y' the number of surviving points. + let y = survivalValues.length; + const points = [{x: 0, y}]; + for (const x of distinctValues) { + y -= dist.get(x)!; + points.push({x, y}); + } + return points; +} + +const cssLabel = styled('div', ` + flex: 1 0 0px; + margin-right: 8px; + + font-weight: initial; /* negate bootstrap */ + color: ${colors.dark}; + overflow: hidden; + text-overflow: ellipsis; +`); + +const cssRowHelp = styled(cssRow, ` + font-size: ${vars.smallFontSize}; + color: ${colors.slate}; +`); diff --git a/app/client/components/ClientScope.ts b/app/client/components/ClientScope.ts new file mode 100644 index 00000000..39344a41 --- /dev/null +++ b/app/client/components/ClientScope.ts @@ -0,0 +1,55 @@ +import * as dispose from 'app/client/lib/dispose'; +import {Storage} from 'app/plugin/StorageAPI'; +import {checkers} from 'app/plugin/TypeCheckers'; +import {Rpc} from 'grain-rpc'; + +/** + * Implementation of interfaces whose lifetime is that of the client. + */ +export class ClientScope extends dispose.Disposable { + private _pluginStorage = new Map(); + + public create() { + // nothing to do + } + + /** + * Make interfaces available for a plugin with a given name. Implementations + * are attached directly to the supplied rpc object. + */ + public servePlugin(pluginId: string, rpc: Rpc) { + // We have just one interface right now, storage. We want to keep ownership + // of storage, so it doesn't go away when the plugin is closed. So we cache + // it. + let storage = this._pluginStorage.get(pluginId); + if (!storage) { + storage = this._implementStorage(); + this._pluginStorage.set(pluginId, storage); + } + rpc.registerImpl("storage", storage, checkers.Storage); + } + + /** + * Create an implementation of the Storage interface. + */ + private _implementStorage(): Storage { + const data = new Map(); + return { + getItem(key: string): any { + return data.get(key); + }, + hasItem(key: string): boolean { + return data.has(key); + }, + setItem(key: string, value: any) { + data.set(key, value); + }, + removeItem(key: string) { + data.delete(key); + }, + clear() { + data.clear(); + }, + }; + } +} diff --git a/app/client/components/Clipboard.css b/app/client/components/Clipboard.css new file mode 100644 index 00000000..d6cf2e83 --- /dev/null +++ b/app/client/components/Clipboard.css @@ -0,0 +1,13 @@ +/** + * With some guidance from Lucidchart: + * https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/ + */ +textarea.copypaste { + position: absolute; + top: -100px; + left: 0; + width: 10px; + height: 10px; + font-size: 1; + z-index: -1; +} diff --git a/app/client/components/Clipboard.js b/app/client/components/Clipboard.js new file mode 100644 index 00000000..5802122d --- /dev/null +++ b/app/client/components/Clipboard.js @@ -0,0 +1,248 @@ +/** + * Clipboard component manages the copy/cut/paste events by capturing these events from the browser, + * managing their state, and exposing an API to other components to get/set the data. + * + * Because of a lack of standardization of ClipboardEvents between browsers, the way Clipboard + * captures the events is by creating a hidden textarea element that's always focused with some text + * selected. Here is a good write-up of this: + * https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/ + * + * When ClipboardEvent is detected, Clipboard captures the event and calls the corresponding + * copy/cut/paste/input command actions, which will get called on the appropriate component. + * + * Usage: + * Components need to register copy/cut/paste actions with command.js: + * .copy() should return @pasteObj (defined below). + * .paste(plainText, [cutSelection]) should take a plainText value and an optional cutSelection + * parameter which will specify the selection that should be cleared as part of paste. + * .input(char) should take a single input character and will be called when the user types a + * visible character (useful if component wants to interpret typing into a cell, for example). + */ + +/** + * Paste object that should be returned by implementation of `copy`. + * + * @typedef pasteObj {{ + * docName: string, + * tableId: string, + * data: object, + * selection: object + * }} + */ + + + +/* global window, document, $ */ + +var ko = require('knockout'); +var {tsvDecode} = require('app/common/tsvFormat'); + +var commands = require('./commands'); +var dom = require('../lib/dom'); +var Base = require('./Base'); +var tableUtil = require('../lib/tableUtil'); + +function Clipboard(app) { + Base.call(this, null); + this._app = app; + this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', '')); + this.timeoutId = null; + + this.onEvent(window, 'focus', this.grabFocus); + this.onEvent(this.copypasteField, 'blur', this.grabFocus); + + this.onEvent(this.copypasteField, 'input', function(elem, event) { + var value = elem.value; + elem.value = ''; + commands.allCommands.input.run(value); + return false; + }); + this.onEvent(this.copypasteField, 'copy', this._onCopy); + this.onEvent(this.copypasteField, 'cut', this._onCut); + this.onEvent(this.copypasteField, 'paste', this._onPaste); + + document.body.appendChild(this.copypasteField); + this.grabFocus(); + + // The following block of code deals with what happens when the window is in the background. + // When it is, focus and blur events are unreliable, and we'll watch explicitly for events which + // may cause a change in focus. These wouldn't happen normally for a background window, but do + // happen in Selenium Webdriver testing. + var grabber = this.grabFocus.bind(this); + function setBackgroundCapture(onOff) { + var addRemove = onOff ? window.addEventListener : window.removeEventListener; + // Note the third argument useCapture=true, which lets us notice these events before other + // code that might call .stopPropagation on them. + addRemove.call(window, 'click', grabber, true); + addRemove.call(window, 'mousedown', grabber, true); + addRemove.call(window, 'keydown', grabber, true); + } + this.onEvent(window, 'blur', setBackgroundCapture.bind(null, true)); + this.onEvent(window, 'focus', setBackgroundCapture.bind(null, false)); + setBackgroundCapture(!document.hasFocus()); + + // Expose the grabber as a global to allow upload from tests to explicitly restore focus + window.gristClipboardGrabFocus = grabber; + + // Some bugs may prevent Clipboard from re-grabbing focus. To limit the impact of such bugs on + // the user, recover from a bad state in mousedown events. (At the moment of this comment, all + // such known bugs are fixed.) + this.onEvent(window, 'mousedown', (ev) => { + if (!document.activeElement || document.activeElement === document.body) { + this.grabFocus(); + } + }); + + // In the event of a cut a callback is provided by the viewsection that is the target of the cut. + // When called it returns the additional removal action needed for a cut. + this._cutCallback = null; + // The plaintext content of the cut callback. Used to verify that we are pasting the results + // of the cut, rather than new data from outside. + this._cutData = null; +} +Base.setBaseFor(Clipboard); + +/** + * Internal helper fired on `copy` events. If a callback was registered from a component, calls the + * callback to get selection data and puts it on the clipboard. + */ +Clipboard.prototype._onCopy = function(elem, event) { + event.preventDefault(); + + let pasteObj = commands.allCommands.copy.run(); + + this._setCBdata(pasteObj, event.originalEvent.clipboardData); +}; + +Clipboard.prototype._onCut = function(elem, event) { + event.preventDefault(); + + let pasteObj = commands.allCommands.cut.run(); + + this._setCBdata(pasteObj, event.originalEvent.clipboardData); +}; + +Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) { + + if (!pasteObj) { + return; + } + + let plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection); + clipboardData.setData('text/plain', plainText); + let htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection); + clipboardData.setData('text/html', htmlText); + + if (pasteObj.cutCallback) { + this._cutCallback = pasteObj.cutCallback; + this._cutData = plainText; + } else { + this._cutCallback = null; + this._cutData = null; + } +}; + +/** + * Internal helper fired on `paste` events. If a callback was registered from a component, calls the + * callback with data from the clipboard. + */ +Clipboard.prototype._onPaste = function(elem, event) { + event.preventDefault(); + let cb = event.originalEvent.clipboardData; + let plainText = cb.getData('text/plain'); + let htmlText = cb.getData('text/html'); + let data; + + // Grist stores both text/html and text/plain when copying data. When pasting back, we first + // check if text/html exists (should exist for Grist and other spreadsheet software), and fall + // back to text/plain otherwise. + try { + data = tableUtil.parsePasteHtml(htmlText); + } catch (e) { + if (plainText === '' || plainText.charCodeAt(0) === 0xFEFF) { + data = [['']]; + } else { + data = tsvDecode(plainText.replace(/\r\n?/g, "\n")); + } + } + + if (this._cutData === plainText) { + if (this._cutCallback) { + // Cuts should only be possible on the first paste after a cut and only if the data being + // pasted matches the data that was cut. + commands.allCommands.paste.run(data, this._cutCallback); + } + } else { + this._cutData = null; + commands.allCommands.paste.run(data, null); + } + // The cut callback should only be usable once so it needs to be cleared after every paste. + this._cutCallback = null; +}; + +/** + * Helper to watch a focused element to lose focus, in which point the Clipboard will grab it. + * Because elements getting removed from the DOM don't always trigger 'blur' event, this also + * watches for the element getting disposed (using ko.removeNode or ko.cleanNode). + */ +Clipboard.prototype._watchElementForBlur = function(elem) { + var self = this; + function done() { + $(elem).off('blur.clipboard'); + ko.utils.domNodeDisposal.removeDisposeCallback(elem, done); + self.grabFocus(); + } + $(elem).one('blur.clipboard', done); + // TODO We need to add proper integration of grainjs and knockout dom-disposal. Otherwise a + // focused node that's disposed by grainjs will not trigger this knockout disposal callback. + ko.utils.domNodeDisposal.addDisposeCallback(elem, done); +}; + +var FOCUS_TARGET_TAGS = { + 'INPUT': true, + 'TEXTAREA': true, + 'SELECT': true, + 'IFRAME': true, +}; + +/** + * Helper to determine if the currently active element deserves to keep its own focus, and capture + * copy-paste events. Besides inputs and textareas, any element can be marked to be a valid + * copy-paste target by adding 'clipboard_focus' class to it. + */ +function isCopyPasteTarget(elem) { + return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) || + elem.hasAttribute("tabindex") || + elem.classList.contains('clipboard_focus')); +} + +/** + * Select the special copypaste field to capture clipboard events. + */ +Clipboard.prototype.grabFocus = function() { + if (!this.timeoutId) { + var self = this; + this.timeoutId = setTimeout(() => { + if (self.isDisposed()) { return; } + self.timeoutId = null; + if (document.activeElement === self.copypasteField) { + return; + } + // If the window doesn't have focus, don't rush to grab it, or we can interfere with focus + // outside the frame when embedded. We'll grab focus when setBackgroundCapture tells us to. + if (!document.hasFocus()) { + return; + } + if (isCopyPasteTarget(document.activeElement)) { + self._watchElementForBlur(document.activeElement); + self._app.trigger('clipboard_blur'); + } else { + self.copypasteField.value = ' '; + self.copypasteField.select(); + self._app.trigger('clipboard_focus'); + } + }, 0); + } +}; + +module.exports = Clipboard; diff --git a/app/client/components/CodeEditorPanel.css b/app/client/components/CodeEditorPanel.css new file mode 100644 index 00000000..1237e5a3 --- /dev/null +++ b/app/client/components/CodeEditorPanel.css @@ -0,0 +1,19 @@ +.g-code-panel { + position:absolute; + width: 100%; + height: 100%; + margin: 10px; + overflow: auto; +} + +.g-code-viewer { + padding: 2rem 1rem; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; + word-wrap: break-word; +} + +.g-code-viewer.hljs { + background-color: inherit; +} diff --git a/app/client/components/CodeEditorPanel.js b/app/client/components/CodeEditorPanel.js new file mode 100644 index 00000000..86bf244d --- /dev/null +++ b/app/client/components/CodeEditorPanel.js @@ -0,0 +1,56 @@ +var _ = require('underscore'); +var ko = require('knockout'); +var BackboneEvents = require('backbone').Events; + +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); + +// Rather than require the whole of highlight.js, require just the core with the one language we +// need, to keep our bundle smaller and the build faster. +var hljs = require('highlight.js/lib/highlight'); +hljs.registerLanguage('python', require('highlight.js/lib/languages/python')); + +function CodeEditorPanel(gristDoc) { + this._gristDoc = gristDoc; + this._schema = ko.observable(''); + + this.listenTo(this._gristDoc, 'schemaUpdateAction', this.onSchemaAction); + this.onSchemaAction(); // Fetch the schema to initialize +} +dispose.makeDisposable(CodeEditorPanel); +_.extend(CodeEditorPanel.prototype, BackboneEvents); + +CodeEditorPanel.prototype.buildDom = function() { + // The tabIndex enables the element to gain focus, and the .clipboard class prevents the + // Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard + // interferes with text selection. TODO it should be possible for the Clipboard to never + // interfere with text selection even for un-focusable elements. + return dom('div.g-code-panel.clipboard', + {tabIndex: "-1"}, + kd.scope(this._schema, function(schema) { + // The reason to scope and rebuild instead of using `kd.text(schema)` is because + // hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree. + return dom( + 'code.g-code-viewer.python', + schema, + dom.hide, + dom.defer(function(elem) { + hljs.highlightBlock(elem); + dom.show(elem); + }) + ); + }) + ); +}; + +CodeEditorPanel.prototype.onSchemaAction = function(actions) { + return this._gristDoc.docComm.fetchTableSchema() + .then(schema => { + if (!this.isDisposed()) { + this._schema(schema); + } + }); +}; + +module.exports = CodeEditorPanel; diff --git a/app/client/components/ColumnFilters.css b/app/client/components/ColumnFilters.css new file mode 100644 index 00000000..1588657e --- /dev/null +++ b/app/client/components/ColumnFilters.css @@ -0,0 +1,141 @@ +/* Hide column menus by default */ +.column_name .g-column-menu-btn { + visibility: hidden; +} + +/* Make visible if open or in column header hover */ +.g-column-menu-btn.open, +.g-column-menu-btn.active, +.column_name:hover .g-column-menu-btn { + visibility: visible; +} + +.g-column-menu-btn.hide-on-inactive:not(.active) { + visibility: hidden; +} + +.g-column-menu-btn > span.glyphicon { + padding: 1px; + margin-left: 2px; + margin-right: 2px; + background-color: #fff; + color: #999; + border: 1px solid #999; + border-radius: 3px; + font-size: 1rem; +} + +.g-column-menu-btn.left-btn > span.glyphicon { + margin: 0 0 0 2px; +} + +.g-column-menu-btn.right-btn > span.glyphicon { + margin: 0 2px 0 0; +} + +.g-column-menu-btn:hover > span.glyphicon { + color: #333; + border: 1px solid #333; +} + +.g-column-menu-btn.active > span.glyphicon { + color: #33f; + border-color: #33f; +} + +.g-column-menu { + position: absolute; + min-width: 180px; + + z-index: 10; + + padding: 4px; + + background-color: #fff; + border: 1px solid #9D8BB5; + box-shadow: 0px 0px 12px #666; + + text-align: left; +} + +.g-column-filter-remove { + float: right; + margin: 6px 0; +} + +.g-column-filter-keyword { + width: 100px; +} + +.g-column-filter-menu { + margin: 6px; + min-width: 250px; +} + +.grist-filter-menu__link { + cursor: pointer; +} + +.g-colfilter-values-scrolly { + position: relative; + height: 200px; + overflow: auto; +} + +.g-colfilter-menu-item { + padding: 1px 8px; + line-height: 1.6rem; + + cursor: default; +} + +.g-colfilter-menu-label { + margin-left: 4px; + margin-right: 4px; +} + +.g-glyphicon-tristate { + position: absolute; + top: 4px; + left: 3px; + width: 5px; + height: 5px; + background: #606060; +} + +.badge-inv { + background-color: #ddd; + color: #666; +} + +.arrow_box { + position: absolute; + background: #fff; + border: 1px solid transparent; + border-top-color: #9D8BB5; + top: -1px; + left: 12px; +} +.arrow_box:after, .arrow_box:before { + bottom: 100%; + left: 50%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.arrow_box:after { + border-color: rgba(255, 255, 255, 0); + border-bottom-color: #fff; + border-width: 8px; + margin-left: -8px; +} +.arrow_box:before { + border-color: rgba(43, 57, 255, 0); + border-bottom-color: #9D8BB5; + border-width: 9px; + margin-left: -9px; +} diff --git a/app/client/components/ColumnTransform.ts b/app/client/components/ColumnTransform.ts new file mode 100644 index 00000000..02ef4c14 --- /dev/null +++ b/app/client/components/ColumnTransform.ts @@ -0,0 +1,183 @@ +/** + * ColumnTransform is used as a abstract base class for any classes which must build a dom for the + * purpose of allowing the user to transform a column. It is currently extended by FormulaTransform + * and TypeTransform. + */ +import * as commands from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ColumnRec} from 'app/client/models/entities/ColumnRec'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {TableData} from 'app/client/models/TableData'; +import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; +import {Disposable, Observable} from 'grainjs'; +import * as ko from 'knockout'; +import noop = require('lodash/noop'); + +// To simplify diff (avoid rearranging methods to satisfy private/public order). +// tslint:disable:member-ordering + +type AceEditor = any; + +/** + * Abstract class for FormulaTransform and TypeTransform to extend. Initializes properties needed + * for both types of transform. optPureType is useful for initializing type transforms. + */ +export class ColumnTransform extends Disposable { + protected field: ViewFieldRec; + protected origColumn: ColumnRec; + protected origDisplayCol: ColumnRec; + protected transformColumn: ColumnRec; // Set in prepare() + protected origWidgetOptions: unknown; + protected isCallPending: ko.Observable; + protected editor: AceEditor|null = null; // Created when the dom is built by extending classes + protected formulaUpToDate = Observable.create(this, true); + protected _tableData: TableData; + + // This is set to true in the interval between execute() and dispose(). + private _isExecuting: boolean = false; + + constructor(protected gristDoc: GristDoc, private _fieldBuilder: FieldBuilder) { + super(); + this.field = _fieldBuilder.field; + this.origColumn = this.field.column(); + this.origDisplayCol = this.field.displayColModel(); + this.origWidgetOptions = this.field.widgetOptionsJson(); + this.isCallPending = _fieldBuilder.isCallPending; + + this._tableData = gristDoc.docData.getTable(this.origColumn.table().tableId())!; + + this.autoDispose(commands.createGroup({ + undo: this.cancel, + redo: noop + }, this, true)); + + this.onDispose(() => { + this._setTransforming(false); + this._fieldBuilder.columnTransform = null; + this.isCallPending(false); + }); + } + + /** + * Build dom function should be implemented by extending classes. + */ + public buildDom() { + throw new Error("Not Implemented"); + } + + public finalize() { + // Implemented in FormulaTransform. + } + + /** + * Build general transform editor dom. + * @param {String} optInit - Optional initial value for the editor. + */ + protected buildEditorDom(optInit?: string) { + return this.editor.buildDom((aceObj: any) => { + this.editor.adjustContentToWidth(); + this.editor.attachSaveCommand(); + aceObj.on('change', () => { + if (this.editor) { + this.formulaUpToDate.set(this.editor.getValue() === this.transformColumn.formula()); + } + }); + aceObj.focus(); + }); + } + + /** + * Helper called by contructor to prepare the column transform. + * @param {String} colType: A pure or complete type for the transformed column. + */ + public async prepare(colType?: string) { + colType = colType || this.origColumn.type.peek(); + // Start bundling all actions during the transform, but include a verification callback to ensure + // no errant actions are added to the bundle. + this._tableData.docData.startBundlingActions(`Transformed column ${this.origColumn.colId()}.`, + action => (action[2] === "gristHelper_Transform" || action[1] === "_grist_Tables_column" || + action[0] === "SetDisplayFormula" || action[1] === "_grist_Views_section_field")); + this.isCallPending(true); + try { + const newColRef = await this.addTransformColumn(colType); + // Set DocModel references + this.field.colRef(newColRef); + this.transformColumn = this.field.column(); + this.transformColumn.origColRef(this.origColumn.getRowId()); + this._setTransforming(true); + return await this.postAddTransformColumn(); + } finally { + this.isCallPending(false); + } + } + + /** + * Adds the tranform column and returns its colRef. May be overridden by derived classes to create + * differently-prepared transform columns. + * @param {String} colType: A pure or complete type for the transformed column. + */ + protected async addTransformColumn(colType: string): Promise { + // Retrieve widget options on prepare (useful for type transforms) + const newColInfo = await this._tableData.sendTableAction(['AddColumn', "gristHelper_Transform", { + type: colType, isFormula: true, formula: this.getIdentityFormula(), + }]); + return newColInfo.colRef; + } + + /** + * A derived class can override to do some processing after this.transformColumn has been set. + */ + protected postAddTransformColumn() { + // Nothing in base class. + } + + public cancel() { + this.field.colRef(this.origColumn.getRowId()); + this._tableData.sendTableAction(['RemoveColumn', this.transformColumn.colId()]); + // TODO: Cancelling a column transform should cancel all involved useractions. + this._tableData.docData.stopBundlingActions(); + this.dispose(); + } + + // TODO: Values flicker during executing since transform column remains a formula as values are copied + // back to the original column. The CopyFromColumn useraction really ought to be "CopyAndRemove" since + // that seems the best way to avoid calculating the formula on wrong values. + protected async execute() { + if (this._isExecuting) { + return; + } + this._isExecuting = true; + + // Define variables used in '.then' since this may be disposed + const transformColId = this.transformColumn.colId(); + const field = this.field; + const fieldBuilder = this._fieldBuilder; + const origRef = this.origColumn.getRowId(); + const tableData = this._tableData; + this.isCallPending(true); + + try { + return await tableData.sendTableAction(['CopyFromColumn', transformColId, this.origColumn.colId(), + JSON.stringify(fieldBuilder.options())]); + } finally { + // Wait until the change completed to set column back, to avoid value flickering. + field.colRef(origRef); + tableData.sendTableAction(['RemoveColumn', transformColId]); + tableData.docData.stopBundlingActions(); + this.dispose(); + } + } + + protected getIdentityFormula() { + return 'return $' + this.origColumn.colId(); + } + + protected _setTransforming(bool: boolean) { + this.origColumn.isTransforming(bool); + this.transformColumn.isTransforming(bool); + } + + protected isExecuting(): boolean { + return this._isExecuting; + } +} diff --git a/app/client/components/Comm.ts b/app/client/components/Comm.ts new file mode 100644 index 00000000..0189659c --- /dev/null +++ b/app/client/components/Comm.ts @@ -0,0 +1,524 @@ +/** + * The Comm object in this module implements communication with the server. We + * communicate via request-response calls, and also receive async messages from + * the server. + * + * In this implementation, a single WebSocket is used for both purposes. + * + * Calls to the server: + * Call a method of the Comm object. The return value is a promise which will + * be fulfilled with the data object of the response, or rejected with + * an error object. + * + * Async messages from the server: + * Listen to Comm for events documented below. + * + * + * Implementation + * -------------- + * Messages are serialized as follows. Note that this is a matter between the client's and the + * server's communication libraries, and code outside of them should not rely on these details. + * Requests: { + * reqId: Number, + * method: String, + * args: Array + * } + * Responses: { + * reqId: Number, // distinguishes responses from async messages + * error: String // if the request failed + * data: Object // if the request succeeded, may be undefined if nothing to return + * } + * Async messages from server: { + * type: String, // 'docListAction' or 'docUserAction' or 'clientConnect' + * docFD: Number, // For 'docUserAction', the file descriptor of the open document. + * data: Object // The message data. + * // other keys may exist depending on message type. + * } + */ + +import {GristWSConnection} from 'app/client/components/GristWSConnection'; +import * as dispose from 'app/client/lib/dispose'; +import {UserAction} from 'app/common/DocActions'; +import {DocListAPI, OpenLocalDocResult} from 'app/common/DocListAPI'; +import {GristServerAPI} from 'app/common/GristServerAPI'; +import {StringUnion} from 'app/common/StringUnion'; +import {getInitialDocAssignment} from 'app/common/urlUtils'; +import {Events as BackboneEvents} from 'backbone'; + +// tslint:disable:no-console + +/** + * Event for a change to the document list. + * These are sent to all connected clients, regardless of which documents they have open. + * TODO: implement and document. + * @event docListAction + */ + +/** + * Event for a user action on a document, or part of one. Sent to all clients that have this + * document open. + * @event docUserAction + * @property {Number} docFD - The file descriptor of the open document, specific to each client. + * @property {Array} data.actionGroup - ActionGroup object containing user action, and doc actions. + * @property {Boolean} fromSelf - Flag to indicate whether the action originated from this client. + */ + +/** + * Event for when a document is forcibly shutdown, and requires the client to re-open it. + * @event docShutdown + * @property {Number} docFD - The file descriptor of the open document, specific to each client. + */ + +/** + * Event sent by server received when a client first connects. + * @event clientConnect + * @property {Number} clientId - The ID for the client, which may be reused if a client reconnects + * to reattach to its state on the server. + * @property {Number} missedMessages - Array of messages missed from the server. + * @property {Object} settings - Object containing server settings and features which + * should be used to initialize the client. + * @property {Object} profile - Object containing session profile information if the user + * is signed in, or null otherwise. See "clientLogin" message below for fields. + */ + +/** + * Event sent by server to all clients in the session when the updated profile is retrieved. + * Does not necessarily contain all properties, may only include updated properties. + * Gets sent on login with all properties. + * @event profileFetch + * @property {String} email User email. + * @property {String} name User name, + * @property {String} imageUrl The url of the user's profile image. + */ + +/** + * Event sent by server to all clients in the session when the user settings are updated. + * @event userSettings + * @property {Object} features - Object containing feature flags such as login, indicating + * which features are activated. + */ + +/** + * Event sent by server to all clients in the session when a client logs out. + * @event clientLogout + */ + +/** + * Event sent by server to all clients when an invite is received or for all invites received + * while away when a user logs in. + * @event receiveInvites + * @property {Number} data - An array of unread invites (see app/common/sharing). + */ + +const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown', + 'clientConnect', 'clientLogout', + 'profileFetch', 'userSettings', 'receiveInvites'); +type ValidEvent = typeof ValidEvent.type; + +/** + * A request that is currently being processed. + */ +export interface CommRequestInFlight { + resolve: (result: any) => void; + reject: (err: Error) => void; + // clientId is non-null for those requests which should not be re-sent on reconnect if + // the clientId has changed; it is null when it's safe to re-send. + clientId: string|null; + docId: string|null; + methodName: string; + requestMsg: string; + sent: boolean; +} + +/** + * A request in the appropriate form for sending to the server. + */ +export interface CommRequest { + reqId: number; + method: string; + args: any[]; +} + +/** + * A regular, successful response from the server. + */ +export interface CommResponse { + reqId: number; + data: any; + error?: null; // TODO: keep until sure server never sets this on regular responses. +} + +/** + * An exceptional response from the server when there is an error. + */ +export interface CommResponseError { + reqId: number; + error: string; + errorCode: string; + shouldFork?: boolean; // if set, the server suggests forking the document. +} + +function isCommResponseError(msg: CommResponse | CommResponseError): msg is CommResponseError { + return Boolean(msg.error); +} + +/** + * A message pushed from the server, not in response to a request. + */ +export interface CommMessage { + type: ValidEvent; + docFD: number; + data: any; +} + +/** + * Comm object provides the interfaces to communicate with the server. + * Each method that calls to the server returns a promise for the response. + */ +export class Comm extends dispose.Disposable implements GristServerAPI, DocListAPI { + // methods defined by GristServerAPI + public logout = this._wrapMethod('logout'); + public updateProfile = this._wrapMethod('updateProfile'); + public getDocList = this._wrapMethod('getDocList'); + public createNewDoc = this._wrapMethod('createNewDoc'); + public importSampleDoc = this._wrapMethod('importSampleDoc'); + public importDoc = this._wrapMethod('importDoc'); + public deleteDoc = this._wrapMethod('deleteDoc'); + // openDoc has special definition below + public renameDoc = this._wrapMethod('renameDoc'); + public getConfig = this._wrapMethod('getConfig'); + public updateConfig = this._wrapMethod('updateConfig'); + public lookupEmail = this._wrapMethod('lookupEmail'); + public getNewInvites = this._wrapMethod('getNewInvites'); + public getLocalInvites = this._wrapMethod('getLocalInvites'); + public ignoreLocalInvite = this._wrapMethod('ignoreLocalInvite'); + public downloadSharedDoc = this._wrapMethod('downloadSharedDoc'); + public showItemInFolder = this._wrapMethod('showItemInFolder'); + public getBasketTables = this._wrapMethod('getBasketTables'); + public embedTable = this._wrapMethod('embedTable'); + public reloadPlugins = this._wrapMethod('reloadPlugins'); + + public pendingRequests: Map; + public nextRequestNumber: number = 0; + + // This is a map from docId to the connection for the server that manages + // that docId. In classic Grist, which doesn't have fixed docIds or multiple + // servers, the key is always "null". + private _connections: Map = new Map(); + private _collectedUserActions: UserAction[] | null; + private _singleWorkerMode: boolean = getInitialDocAssignment() === null; // is this classic Grist? + private listenTo: BackboneEvents["listenTo"]; // set by Backbone + private trigger: BackboneEvents["trigger"]; // set by Backbone + private stopListening: BackboneEvents["stopListening"]; // set by Backbone + + public create() { + this.autoDisposeCallback(() => { + for (const connection of this._connections.values()) { connection.dispose(); } + this._connections.clear(); + }); + this.pendingRequests = new Map(); + this.nextRequestNumber = 0; + + // If collecting is turned on (by tests), this will be a list of UserActions sent to the server. + this._collectedUserActions = null; + } + + /** + * Initialize a connection. For classic Grist, with a single server + * and mutable document identifiers, we will only ever have one + * connection, shared for all uses. For hosted Grist, with + * permanent docIds which map to potentially distinct servers, we + * have one connection per document. + * + * For classic grist, the docId passed here has no effect, and can + * be null. For hosted Grist, if the docId is null, the id will be + * read from the configuration object sent by the server. This + * allows the Comm object to be initialized at the same stage as + * it has been classically, eliminating a source of changes in timing + * that could effect old tests. + */ + public initialize(docId: string|null): GristWSConnection { + docId = docId || getInitialDocAssignment(); + let connection = this._connections.get(docId); + if (connection) { return connection; } + connection = GristWSConnection.create(null); + this._connections.set(docId, connection); + this.listenTo(connection, 'serverMessage', this._onServerMessage.bind(this, docId)); + this.listenTo(connection, 'connectionStatus', (message: any, status: any) => { + this.trigger('connectionStatus', message, status); + }); + this.listenTo(connection, 'connectState', () => { + const isConnected = [...this._connections.values()].some(c => c.established); + this.trigger('connectState', isConnected); + }); + + connection.initialize(docId); + return connection; + } + + // Returns a map of docId -> docWorkerUrl for existing connections, for testing. + public listConnections(): Map { + return new Map(Array.from(this._connections, ([docId, conn]) => [docId, conn.getDocWorkerUrlOrNull()])); + } + + /** + * The openDoc method is special, in that it is the first point at which + * we commit to a particular document. It is also the only method not + * committed to a document that is called in hosted Grist - all other methods + * are called via DocComm. + */ + public async openDoc(docName: string, mode?: string): Promise { + return this._makeRequest(null, docName, 'openDoc', docName, mode); + } + + /** + * Ensure we have a connection to a docWorker serving docId, and mark it as in use by + * incrementing its useCount. This connection will not be disposed until a corresponding + * releaseDocConnection() is called. + */ + public useDocConnection(docId: string): GristWSConnection { + const connection = this._connection(docId); + connection.useCount += 1; + console.log(`Comm.useDocConnection(${docId}): useCount now ${connection.useCount}`); + return connection; + } + + /** + * Remove a connection associated with a particular document. In classic grist, we skip removal, + * since all docs use the same server. + * This should be called in pair with a preceding useDocConnection() call. It decrements the + * connection's useCount, and disposes it when it's no longer in use. + */ + public releaseDocConnection(docId: string): void { + const connection = this._connections.get(docId); + if (connection) { + connection.useCount -= 1; + console.log(`Comm.releaseDocConnection(${docId}): useCount now ${connection.useCount}`); + // Dispose the connection if it is no longer in use (except in "classic grist"). + if (!this._singleWorkerMode && connection.useCount <= 0) { + this.stopListening(connection); + connection.dispose(); + this._connections.delete(docId); + this._rejectRequests(docId); + } + } + } + + /** + * Starts or stops the collection of UserActions. + */ + public userActionsCollect(optYesNo?: boolean): void { + this._collectedUserActions = optYesNo === false ? null : []; + } + + /** + * Returns all UserActions collected since collection started or since previous call. + */ + public userActionsFetchAndReset(): UserAction[] { + return this._collectedUserActions ? this._collectedUserActions.splice(0) : []; + } + + /** + * Add UserActions to a list, for use in tests. Called by DocComm. + */ + public addUserActions(actions: UserAction[]) { + // Note: collecting user-actions for testing is in Comm mainly for historical reasons. + if (this._collectedUserActions) { + this._collectedUserActions.push(...actions); + } + } + + /** + * Returns a url to the worker serving the specified document. + */ + public getDocWorkerUrl(docId: string|null): string { + return this._connection(docId).docWorkerUrl; + } + + /** + * Returns true if there is one or more request that has not been fully processed. + */ + public hasActiveRequests(): boolean { + return this.pendingRequests.size !== 0; + } + + /** + * Wait for all active requests to complete. + */ + public async waitForActiveRequests(): Promise { + await Promise.all(this.pendingRequests.values()); + } + + /** + * Internal implementation of all the server methods. They differ only in the name of the server + * method to call, and the arguments that it expects. + * + * This is made public for DocComm's use. Regular code should not call _makeRequest directly. + * + * @param {String} clientId - If non-null, we ensure that it matches the current clientId, + * rejecting the call otherwise. It should be bound to the session's clientId for + * session-specific calls, so that we can't send requests to the wrong session. See openDoc(). + * @param {String} methodName - The name of the server method to call. + * @param {...} varArgs - Other method-specific arguments to send to the server. + * @returns {Promise} Promise for the response. The server may fulfill or reject it, or it may be + * rejected in case of a disconnect. + */ + public async _makeRequest(clientId: string|null, docId: string|null, + methodName: string, ...args: any[]): Promise { + const connection = this._connection(docId); + if (clientId !== null && clientId !== connection.clientId) { + console.log("Comm: Rejecting " + methodName + " for outdated clientId %s (current %s)", + clientId, connection.clientId); + return Promise.reject(new Error('Comm: outdated session')); + } + const request: CommRequest = { + reqId: this.nextRequestNumber++, + method: methodName, + args + }; + console.log("Comm request #" + request.reqId + " " + methodName, request.args); + return new Promise((resolve, reject) => { + const requestMsg = JSON.stringify(request); + const sent = connection.send(requestMsg); + this.pendingRequests.set(request.reqId, { + resolve, + reject, + clientId, + docId, + methodName, + requestMsg, + sent + }); + }); + } + + /** + * Create a connection to the specified document, or return an already open connection + * that that document. For a docId of null, any open connection will be returned, and + * an error is thrown if no connection is already open. + */ + private _connection(docId: string|null): GristWSConnection { + // for classic Grist, "docIds" are untrustworthy doc names, but on the plus side + // we only need one connections - so just replace docId with a constant. + if (this._singleWorkerMode) { docId = null; } + if (docId === null) { + if (this._connections.size > 0) { + return this._connections.values().next().value; + } + throw new Error('no connection available'); + } + const connection = this._connections.get(docId); + if (!connection) { + return this.initialize(docId); + } + return connection; + } + + /** + * If GristWSConnection for a docId is disposed, requests that were sent to that doc will never + * resolve. Reject them instead here. + */ + private _rejectRequests(docId: string|null) { + const error = "GristWSConnection disposed"; + for (const [reqId, req] of this.pendingRequests) { + if (reqMatchesConnection(req.docId, docId)) { + console.log(`Comm: Rejecting req #${reqId} ${req.methodName}: ${error}`); + this.pendingRequests.delete(reqId); + req.reject(new Error('Comm: ' + error)); + } + } + } + + /** + * + * This module automatically logs any errors to the console, so callers an provide an empty + * error-handling function if logging is all they need on error. + * + * We should watch timeouts, and log something when there is no response for a while. + * There is probably no need for callers to deal with timeouts. + */ + private _onServerMessage(docId: string|null, + message: CommResponse | CommResponseError | CommMessage) { + if ('reqId' in message) { + const reqId = message.reqId; + const r = this.pendingRequests.get(reqId); + if (r) { + this.pendingRequests.delete(reqId); + if ('errorCode' in message && message.errorCode === 'AUTH_NO_VIEW') { + // We should only arrive here if the user had view access, and then lost it. + // We should not let the user see the document any more. Let's reload the + // page, reducing this to the problem of arriving at a document the user + // doesn't have access to, which is already handled. + console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`); + window.location.reload(); + } + if (isCommResponseError(message)) { + const err: any = new Error(message.error); + let code = ''; + if (message.errorCode) { + code = ` [${message.errorCode}]`; + err.code = message.errorCode; + } + err.shouldFork = message.shouldFork; + console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}` + + (message.shouldFork ? ` (should fork)` : '')); + r.reject(err); + } else { + console.log(`Comm response #${reqId} ${r.methodName} OK`); + r.resolve(message.data); + } + } else { + console.log("Comm: Response to unknown reqId " + reqId); + } + } else { + if (message.type === 'clientConnect') { + // Reject or re-send any pending requests as appropriate in the order in which they were + // added to the pendingRequests map. + for (const [id, req] of this.pendingRequests) { + if (reqMatchesConnection(req.docId, docId)) { + this._resendPendingRequest(id, req); + } + } + } + + // Another asynchronous message that's not a response. Broadcast it as an event. + if (ValidEvent.guard(message.type)) { + console.log("Comm: Triggering event " + message.type); + this.trigger(message.type, message); + } else { + console.log("Comm: Server message of unknown type " + message.type); + } + } + } + + private _resendPendingRequest(reqId: number, r: CommRequestInFlight) { + let error = null; + const connection = this._connection(r.docId); + if (r.sent) { + // If we sent a request, and reconnected before getting a response, we don't know what + // happened. The safer choice is to reject the request. + error = "interrupted by reconnect"; + } else if (r.clientId !== null && r.clientId !== connection.clientId) { + // If we are waiting to send this request for a particular clientId, but clientId changed. + error = "pending with outdated clientId"; + } else { + // Waiting to send the request, and clientId is fine: go ahead and send it. + r.sent = connection.send(r.requestMsg); + } + if (error) { + console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error); + this.pendingRequests.delete(reqId); + r.reject(new Error('Comm: ' + error)); + } + } + + private _wrapMethod(name: Name): GristServerAPI[Name] { + return this._makeRequest.bind(this, null, null, name); + } +} + +Object.assign(Comm.prototype, BackboneEvents); + +function reqMatchesConnection(reqDocId: string|null, connDocId: string|null) { + return reqDocId === connDocId || !reqDocId || !connDocId; +} diff --git a/app/client/components/Confirm.ts b/app/client/components/Confirm.ts new file mode 100644 index 00000000..78955165 --- /dev/null +++ b/app/client/components/Confirm.ts @@ -0,0 +1,30 @@ +// Grist client libs +import * as ModalDialog from 'app/client/components/ModalDialog'; +import * as dom from 'app/client/lib/dom'; +import * as kd from 'app/client/lib/koDom'; +import * as kf from 'app/client/lib/koForm'; + +export function showConfirmDialog(title: string, btnText: string, onConfirm: () => Promise, + explanation?: Element|string): void { + const body = dom('div.confirm', + explanation ? kf.row(explanation, kd.style('margin-bottom', '2rem')) : null, + kf.row( + 1, kf.buttonGroup( + kf.button(() => dialog.hide(), 'Cancel') + ), + 1, kf.buttonGroup( + kf.accentButton(async () => { + await onConfirm(); + dialog.hide(); + }, btnText) + ) + ) + ); + const dialog = ModalDialog.create({ + title, + body, + width: '300px', + show: true + }); + dialog.once('close', () => dialog.dispose()); +} diff --git a/app/client/components/CopySelection.js b/app/client/components/CopySelection.js new file mode 100644 index 00000000..d873baed --- /dev/null +++ b/app/client/components/CopySelection.js @@ -0,0 +1,42 @@ +var ValueFormatter = require('app/common/ValueFormatter'); + +/** + * The CopySelection class is an abstraction for a subset of currently selected cells. + * @param {Array} rowIds - row ids of the rows selected + * @param {Array} fields - MetaRowModels of the selected view fields + * @param {Object} options.rowStyle - an object that maps rowId to an object containing + * style options. i.e. { 1: { height: 20px } } + * @param {Object} options.colStyle - an object that maps colId to an object containing + * style options. + */ + +function CopySelection(tableData, rowIds, fields, options) { + this.fields = fields; + this.rowIds = rowIds || []; + this.colIds = fields.map(f => f.colId()); + this.displayColIds = fields.map(f => f.displayColModel().colId()); + this.rowStyle = options.rowStyle; + this.colStyle = options.colStyle; + this.columns = fields.map((f, i) => { + let formatter = ValueFormatter.createFormatter( + f.displayColModel().type(), f.widgetOptionsJson()); + let _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]); + let _rawGetter = tableData.getRowPropFunc(this.colIds[i]); + + return { + colId: this.colIds[i], + fmtGetter: rowId => formatter.formatAny(_fmtGetter(rowId)), + rawGetter: rowId => _rawGetter(rowId) + }; + }); +} + +CopySelection.prototype.isCellSelected = function(rowId, colId) { + return this.rowIds.includes(rowId) && this.colIds.includes(colId); +}; + +CopySelection.prototype.onlyAddRowSelected = function() { + return this.rowIds.length === 1 && this.rowIds[0] === "new"; +}; + +module.exports = CopySelection; diff --git a/app/client/components/Cursor.ts b/app/client/components/Cursor.ts new file mode 100644 index 00000000..00be1882 --- /dev/null +++ b/app/client/components/Cursor.ts @@ -0,0 +1,133 @@ +/** + * The Cursor module contains functionality related to the cell with the cursor, i.e. a single + * currently selected cell. + */ + + +import * as BaseView from 'app/client/components/BaseView'; +import * as commands from 'app/client/components/commands'; +import * as BaseRowModel from 'app/client/models/BaseRowModel'; +import {LazyArrayModel} from 'app/client/models/DataTableModel'; +import {Disposable} from 'grainjs'; +import * as ko from 'knockout'; + +export interface CursorPos { + rowId?: number; + rowIndex?: number; + fieldIndex?: number; + sectionId?: number; +} + +function nullAsUndefined(value: number|null|undefined): number|undefined { + return value == null ? undefined : value; +} + +/** + * Cursor represents the location of the cursor in the viewsection. It is maintained by BaseView, + * and implements the shared functionality related to the cursor cell. + * @param {BaseView} baseView: The BaseView object to which this Cursor belongs. + * @param {Object} optCursorPos: Optional object containing rowId and fieldIndex properties + * to which the cursor should be initialized. + */ +export class Cursor extends Disposable { + /** + * The commands closely tied to the cursor. They are active when the BaseView containing this + * Cursor has focus. Some may need to be overridden by particular views. + */ + public static editorCommands = { + // The cursor up/down commands may need to be a bit different in non-grid views. + cursorUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 1); }, + cursorDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 1); }, + cursorLeft(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); }, + cursorRight(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); }, + skipUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 5); }, + skipDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 5); }, + pageUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 20); }, // TODO Not really pageUp + pageDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 20); }, // TODO Not really pageDown + prevField(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); }, + nextField(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); }, + moveToFirstRecord(this: Cursor) { this.rowIndex(0); }, + moveToLastRecord(this: Cursor) { this.rowIndex(Infinity); }, + moveToFirstField(this: Cursor) { this.fieldIndex(0); }, + moveToLastField(this: Cursor) { this.fieldIndex(Infinity); }, + + // Command to be manually triggered on cell selection. Moves the cursor to the selected cell. + // This is overridden by the formula editor to insert "$col" variables when clicking cells. + setCursor(this: Cursor, rowModel: BaseRowModel, colModel: BaseRowModel) { + this.rowIndex(rowModel ? rowModel._index() : 0); + this.fieldIndex(colModel ? colModel._index()! : 0); + }, + }; + + public viewData: LazyArrayModel; + + public rowIndex: ko.Computed; // May be null when there are no rows. + public fieldIndex: ko.Observable; + + private _rowId: ko.Observable; // May be null when there are no rows. + + // The cursor's _rowId property is always fixed across data changes. When isLive is true, + // the rowIndex of the cursor is recalculated to match _rowId. When false, they will + // be out of sync. + private _isLive: ko.Observable = ko.observable(true); + + constructor(baseView: BaseView, optCursorPos?: CursorPos) { + super(); + optCursorPos = optCursorPos || {}; + this.viewData = baseView.viewData; + + this._rowId = ko.observable(optCursorPos.rowId || 0); + this.rowIndex = this.autoDispose(ko.computed({ + read: () => { + if (!this._isLive()) { return this.rowIndex.peek(); } + const rowId = this._rowId(); + return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId)); + }, + write: (index) => { + const rowIndex = this.viewData.clampIndex(index!); + this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex)); + }, + })); + + this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0); + this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus)); + + // Update the section's activeRowId when the cursor's rowId changes. + this.autoDispose(this._rowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId))); + + // On dispose, save the current cursor position to the section model. + this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); }); + } + + // Returns the cursor position with rowId, rowIndex, and fieldIndex. + public getCursorPos(): CursorPos { + return { + rowId: nullAsUndefined(this._rowId()), + rowIndex: nullAsUndefined(this.rowIndex()), + fieldIndex: this.fieldIndex() + }; + } + + /** + * Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid, + * preferring rowId. + * @param cursorPos: Position as { rowId?, rowIndex?, fieldIndex? }, as from getCursorPos(). + */ + public setCursorPos(cursorPos: CursorPos): void { + if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) { + this._rowId(cursorPos.rowId); + } else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) { + this.rowIndex(cursorPos.rowIndex); + } else { + // Write rowIndex to itself to force an update of rowId if needed. + this.rowIndex(this.rowIndex.peek()); + } + if (cursorPos.fieldIndex !== undefined) { + this.fieldIndex(cursorPos.fieldIndex); + } + } + + public setLive(isLive: boolean): void { + this._isLive(isLive); + } +} diff --git a/app/client/components/CustomView.css b/app/client/components/CustomView.css new file mode 100644 index 00000000..b199ba04 --- /dev/null +++ b/app/client/components/CustomView.css @@ -0,0 +1,9 @@ +iframe.custom_view { + border: none; + height: 100%; +} + +.custom_view_notification { + padding: 15px; + margin: 15px; +} diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts new file mode 100644 index 00000000..54c8e03b --- /dev/null +++ b/app/client/components/CustomView.ts @@ -0,0 +1,300 @@ +import * as BaseView from 'app/client/components/BaseView'; +import {Cursor} from 'app/client/components/Cursor'; +import { GristDoc } from 'app/client/components/GristDoc'; +import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals'; +import { CustomSectionElement, ViewProcess } from 'app/client/lib/CustomSectionElement'; +import { Disposable } from 'app/client/lib/dispose'; +import * as dom from 'app/client/lib/dom'; +import * as kd from 'app/client/lib/koDom'; +import * as DataTableModel from 'app/client/models/DataTableModel'; +import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; +import { CustomViewSectionDef } from 'app/client/models/entities/ViewSectionRec'; +import {SortedRowSet} from 'app/client/models/rowset'; +import { BulkColValues, RowRecord } from 'app/common/DocActions'; +import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; +import { PluginInstance } from 'app/common/PluginInstance'; +import {GristView} from 'app/plugin/GristAPI'; +import {Events as BackboneEvents} from 'backbone'; +import {MsgType, Rpc} from 'grain-rpc'; +import * as ko from 'knockout'; +import debounce = require('lodash/debounce'); +import defaults = require('lodash/defaults'); +import noop = require('lodash/noop'); + +const G = getBrowserGlobals('window'); + +/** + * CustomView components displays arbitrary html. There are two modes available, in the "url" mode + * the content is hosted by a third-party (for instance a github page), as opposed to the "plugin" + * mode where the contents is provided by a plugin. In both cases the content is rendered safely + * within an iframe (or webview if running electron). Configuration of the component is done within + * the view config tab in the side pane. In "plugin" mode, shows notification if either the plugin + * of the section could not be found. + */ +export class CustomView extends Disposable { + + /** + * The HTMLElement embedding the content. + */ + public viewPane: HTMLElement; + + // viewSection, sortedRows, tableModel, gristDoc, and cursor are inherited from BaseView + protected viewSection: ViewSectionRec; + protected sortedRows: SortedRowSet; + protected tableModel: DataTableModel; + protected gristDoc: GristDoc; + protected cursor: Cursor; + + private _customDef: CustomViewSectionDef; + + // state of the component + private _foundPlugin: ko.Observable; + private _foundSection: ko.Observable; + // Note the invariant: this._customSection != undefined if this._foundSection() == true + private _customSection: ViewProcess|undefined; + private _pluginInstance: PluginInstance|undefined; + + private _updateData: () => void; // debounced call to let the view know linked data changed. + private _updateCursor: () => void; // debounced call to let the view know linked cursor changed. + private _rpc: Rpc; // rpc connection to view. + + public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { + BaseView.call(this as any, gristDoc, viewSectionModel); + + this._customDef = this.viewSection.customDef; + + this.autoDisposeCallback(() => { + if (this._customSection) { + this._customSection.dispose(); + } + }); + this._foundPlugin = ko.observable(false); + this._foundSection = ko.observable(false); + // Ensure that selecting another section in same plugin update the view. + this._foundSection.extend({notify: 'always'}); + + this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this)); + this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this)); + + this.viewPane = this.autoDispose(this._buildDom()); + this._updatePluginInstance(); + + this._updateData = debounce(() => this._updateView(true), 0); + this._updateCursor = debounce(() => this._updateView(false), 0); + + this.autoDispose(this.viewSection.viewFields().subscribe(this._updateData)); + this.listenTo(this.sortedRows, 'rowNotify', this._updateData); + this.autoDispose(this.sortedRows.getKoArray().subscribe(this._updateData)); + + this.autoDispose(this.cursor.rowIndex.subscribe(this._updateCursor)); + } + + private _updateView(dataChange: boolean) { + if (this.isDisposed()) { return; } + if (this._rpc) { + const state = { + tableId: this.viewSection.table().tableId(), + rowId: this.cursor.getCursorPos().rowId || undefined, + dataChange + }; + // tslint:disable-next-line:no-console + this._rpc.postMessage(state).catch(e => console.error('Failed to send view state', e)); + // This post message won't get through if doc access has not been granted to the view. + } + } + + /** + * Find a plugin instance that matchs the plugin id, update the `found` observables, then tries to + * find a matching section. + */ + private _updatePluginInstance() { + + const pluginId = this._customDef.pluginId(); + this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId); + + if (this._pluginInstance) { + this._foundPlugin(true); + } else { + this._foundPlugin(false); + this._foundSection(false); + } + this._updateCustomSection(); + } + + /** + * If a plugin was found, find a custom section matching the section id and update the `found` + * observables. + */ + private _updateCustomSection() { + + if (!this._pluginInstance) { return; } + + const sectionId = this._customDef.sectionId(); + this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId); + + if (this._customSection) { + const el = this._customSection.element; + el.classList.add("flexitem"); + this._foundSection(true); + } else { + this._foundSection(false); + } + + } + + /** + * Access data backing the section as a table. This code is borrowed + * with variations from ChartView.ts. + */ + private _getSelectedTable(): BulkColValues { + const fields: ViewFieldRec[] = this.viewSection.viewFields().all(); + const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[]; + const data: BulkColValues = {}; + for (const field of fields) { + // Use the colId of the displayCol, which may be different in case of Reference columns. + const colId: string = field.displayColModel.peek().colId.peek(); + const getter = this.tableModel.tableData.getRowPropFunc(colId)!; + const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); + data[field.column().colId()] = rowIds.map((r) => reencodeAsAny(getter(r)!, typeInfo)); + } + data.id = rowIds; + return data; + } + + private _getSelectedRecord(rowId: number): RowRecord { + // Prepare an object containing the fields available to the view + // for the specified row. A RECORD()-generated rendering would be + // more useful. but the data engine needs to know what information + // the custom view depends on, so we shouldn't volunteer any untracked + // information here. + const fields: ViewFieldRec[] = this.viewSection.viewFields().all(); + const data: RowRecord = {id: rowId}; + for (const field of fields) { + const colId: string = field.displayColModel.peek().colId.peek(); + const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); + data[field.column().colId()] = reencodeAsAny(this.tableModel.tableData.getValue(rowId, colId)!, typeInfo); + } + return data; + } + + private _buildDom() { + const {mode, url, access} = this._customDef; + const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin"); + + // When both plugin and section are not found, let's show only plugin notification. + const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin()); + const showSectionNotification = ko.pureComputed(() => showPlugin() && this._foundPlugin() && !this._foundSection()); + const showPluginContent = ko.pureComputed(() => showPlugin() && this._foundSection()) + // For the view to update when switching from one section to another one, the computed + // observable must always notify. + .extend({notify: 'always'}); + return dom('div.flexauto.flexvbox.custom_view_container', + dom.autoDispose(showPlugin), + dom.autoDispose(showPluginNotification), + dom.autoDispose(showSectionNotification), + dom.autoDispose(showPluginContent), + // todo: should display content in webview when running electron + kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) => + _mode === "url" ? this._buildIFrame(_url, _access) : null), + kd.maybe(showPluginNotification, () => buildNotification('Plugin ', + dom('strong', kd.text(this._customDef.pluginId)), ' was not found', + dom.testId('customView_notification_plugin') + )), + kd.maybe(showSectionNotification, () => buildNotification('Section ', + dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ', + dom('strong', kd.text(this._customDef.pluginId)), + dom.testId('customView_notification_section') + )), + // When showPluginContent() is true then _foundSection() is also and _customSection is not + // undefined (invariant). + kd.maybe(showPluginContent, () => this._customSection!.element) + ); + } + + private _buildIFrame(baseUrl: string, access: string) { + // This is a url-flavored custom view. + // Here we create an iframe, and add hooks for sending + // messages to it and receiving messages from it. + + // Compute a url for the view. We add in a parameter called "access" + // so the page can determine what access level has been granted to it + // in a simple and unambiguous way. + let fullUrl: string; + if (!baseUrl) { + fullUrl = baseUrl; + } else { + const url = new URL(baseUrl); + url.searchParams.append('access', access); + fullUrl = url.href; + } + + if (!access) { access = 'none'; } + const someAccess = (access !== 'none'); + const fullAccess = (access === 'full'); + + // Create an Rpc object to manage messaging. If full access is granted, + // allow forwarding to the back-end; otherwise restrict to APIs explicitly + // made available here. + const rpc = fullAccess ? this.gristDoc.docPluginManager.makeAnonForwarder() : + new Rpc({}); + // Now, we create a listener for message events (if access was granted), making sure + // to respond only to messages from our iframe. + const listener = someAccess ? (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + rpc.receiveMessage(event.data); + if (event.data.mtype === MsgType.Ready) { + // After, the "ready" message, send a notification with cursor + // (if available). + this._updateView(true); + } + } + } : null; + // Add the listener only if some access has been granted. + if (listener) { G.window.addEventListener('message', listener); } + // Here is the actual iframe. + const iframe = dom('iframe.custom_view.clipboard_focus', + {src: fullUrl}, + dom.onDispose(() => { + if (listener) { G.window.removeEventListener('message', listener); } + })); + if (someAccess) { + // When replies come back, forward them to the iframe if access + // is granted. + rpc.setSendMessage(msg => { + iframe.contentWindow!.postMessage(msg, '*'); + }); + // Register a way for the view to access the data backing the view. + rpc.registerImpl('GristView', { + fetchSelectedTable: () => this._getSelectedTable(), + fetchSelectedRecord: (rowId: number) => this._getSelectedRecord(rowId), + }); + } else { + // Direct messages to /dev/null otherwise. Important to setSendMessage + // or they will be queued indefinitely. + rpc.setSendMessage(noop); + } + // We send events via the rpc object when the data backing the view changes + // or the cursor changes. + if (this._rpc) { + // There's an existing RPC object we are replacing. + // Unregister anything that may have been registered previously. + // TODO: add a way to clean up more systematically to grain-rpc. + this._rpc.unregisterForwarder('*'); + this._rpc.unregisterImpl('GristView'); + } + this._rpc = rpc; + return iframe; + } + + private listenTo(...args: any[]): void { /* replaced by Backbone */ } +} + +// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts +defaults(CustomView.prototype, BaseView.prototype); +Object.assign(CustomView.prototype, BackboneEvents); + + +// helper to build the notification's frame. +function buildNotification(...args: any[]) { + return dom('div.custom_view_notification.bg-warning', dom('p', ...args)); +} diff --git a/app/client/components/DetailView.css b/app/client/components/DetailView.css new file mode 100644 index 00000000..0a401c5d --- /dev/null +++ b/app/client/components/DetailView.css @@ -0,0 +1,270 @@ +.detail_menu_bottom { + border-top: 1px solid lightgrey; +} + +/* applies to the record detail container */ +.record-layout-editor { + position: absolute; + top: 0; + left: 0; + width: 100%; + + background: white; + z-index: 1; + margin-top: -3px; +} + +.g_record_detail_inner > .layout_root { + height: auto; +} + +/* applies to all record details */ +.g_record_detail_el { + position: relative; + margin: 0.5rem; + padding: .5rem; +} + +.g_record_detail_label { + min-height: 1rem; + color: #666; + font-size: 1rem; + font-weight: bold; +} + +.g_record_detail_value { + position: relative; + min-height: 16px; + white-space: pre; + word-wrap: break-word; +} + +.g_record_detail_value.record-add { + background-color: #f6f6ff; +} + +.g_record_detail_value.scissors { + outline: 2px dashed var(--grist-color-cursor); +} + +.detail_row_num { + text-align: right; + font-size: var(--grist-x-small-font-size); + font-weight: normal; + color: var(--grist-color-slate); + padding: 8px; +} + +.detail_row_num::before { + content: "ROW "; +} + +.detail-left.disabled, .detail-right.disabled, .detail-add-btn.disabled { + cursor: default !important; +} + +.detail-add-grp { + margin-left: 0.5rem; +} + +/*** card view (multiple records) ***/ + +.detailview_scroll_pane { + position: relative; + overflow-y: auto; + overflow-x: hidden; + + /* allow 3px to the left to be visible, for highlighting active record */ + padding-left: 3px; + margin-left: -3px; +} + +.detailview_record_detail.active { + /* highlight active record in Card List by overlaying the active-section highlight */ + margin-left: -3px; + border-left: 3px solid var(--grist-color-light-green); +} + +/*** single record ***/ +.detailview_single { + overflow: auto; +} + +.grist-single-record__menu { + padding: .2rem .5rem .2rem 0; + align-items: center; + flex-shrink: 0; +} + +.grist-single-record__menu.newui { + padding: 0; + margin-top: -4px; +} + +.grist-single-record__menu__count { + white-space: nowrap; + text-align: right; + padding-right: 1rem; +} + +.detailview_record_single > .detail_row_num { + display: none; +} + +/*** detailed record "themes" ***/ + +/*** label-under theme ***/ +/* TODO Deprecated. Probably best to keep styles for the sake of older docs that might specify + * this theme, but in practice it's unlikely any docs use it. + */ +.detail_theme_field_under { + display: flex; + display: -webkit-flex; + flex-direction: column-reverse; + -webkit-flex-direction: column-reverse; +} + +.detail_theme_field_under > .g_record_detail_label { + border-top: 1px solid #333; +} + +.detail_theme_record_under { + border-top: 1px solid #ccc; + padding: 0 1rem 1rem 0; + border-left: 2px solid white; +} + +.detail_theme_record_under:first-child { + border-top: none; +} + +/*** compact theme ***/ +.detail_theme_record_compact { + /* 12px is enough margin on the right to include most of the floating scrollbar on MacOS */ + padding: 4px 16px 0px 16px; + background-color: var(--grist-color-medium-grey); +} + +.detail_theme_record_compact.detailview_record_single { + padding: 8px; +} + +.detail_theme_record_compact > .detail_row_num { + padding: 0px; +} + +.detail_theme_record_compact > .g_record_detail_inner { + background-color: white; + position: relative; +} + +.detail_theme_record_compact > .g_record_detail_inner > .layout_root { + border: 1px solid var(--grist-color-dark-grey); + border-right: none; + border-bottom: none; +} + +.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner { + height: 100%; +} + +.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner > .layout_root { + height: 100%; +} + +.detail_theme_field_compact { + border-top: none; + border-left: none; + border-right: 1px solid var(--grist-color-dark-grey); + border-bottom: 1px solid var(--grist-color-dark-grey); + padding: 1px 1px 1px 5px; + margin: 0; + line-height: 1.2; +} + +.detail_theme_field_compact > .g_record_detail_label { + font-weight: normal; + font-size: var(--grist-small-font-size); + color: var(--grist-color-slate); + min-height: 0px; + + white-space: nowrap; + overflow: hidden; + margin-left: 3px; /* to align with the .field_clip content */ + margin-right: -1px; /* allow labels to overflow into the padding */ +} + +/*** form theme ***/ + +.detail_theme_field_form { + padding: 1px 1px 1px 5px; +} + +.detail_theme_field_form > .g_record_detail_label { + font-size: var(--grist-small-font-size); + color: var(--grist-color-slate); + font-weight: bold; + min-height: 0px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: -8px; +} + +/* TODO want to style better the values themselves (e.g. more padding, rounded corners, move label + * inside value box for compact view for better cursor looks, etc), but first the cell editor + * needs to learn to match the value box's style. Right now, the cell editor style is hard-coded. + */ +.detail_theme_field_form > .g_record_detail_value { + border: 1px solid lightgrey; +} + +.detail_theme_record_form { + padding: 0px 12px 0px 8px; +} + +.detail_theme_record_form.detailview_record_single { + padding-top: 8px; +} + +.detail_theme_record_form.detailview_record_detail { + border-bottom: 1px solid var(--grist-color-dark-grey); + padding-bottom: 12px; +} + +/*** blocks theme ***/ + +.detail_theme_record_blocks { + padding: 0px 12px 0px 8px; +} + +.detail_theme_record_blocks > .detail_row_num { + padding-bottom: 0px; +} + +.detail_theme_record_blocks.detailview_record_single { + padding: 8px; +} + +.detail_theme_record_blocks.detailview_record_detail { + border-bottom: 1px solid var(--grist-color-dark-grey); + padding-bottom: 8px; +} + +.detail_theme_field_blocks { + padding: 6px; + margin: 8px; + background-color: var(--grist-color-medium-grey); + border-radius: 2px; +} + +.detail_theme_field_blocks > .g_record_detail_label { + font-size: var(--grist-small-font-size); + color: var(--grist-color-slate); + font-weight: normal; + white-space: nowrap; + overflow: hidden; + margin-left: 3px; /* to align with the .field_clip content */ + margin-right: -6px; /* allow labels to overflow into the padding */ + margin-bottom: 4px; +} diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js new file mode 100644 index 00000000..9e9939be --- /dev/null +++ b/app/client/components/DetailView.js @@ -0,0 +1,394 @@ +var _ = require('underscore'); +var ko = require('knockout'); + +var dom = require('app/client/lib/dom'); +var kd = require('app/client/lib/koDom'); +var koDomScrolly = require('app/client/lib/koDomScrolly'); + +require('app/client/lib/koUtil'); // Needed for subscribeInit. + +var Base = require('./Base'); +var BaseView = require('./BaseView'); +var CopySelection = require('./CopySelection'); +var RecordLayout = require('./RecordLayout'); +var commands = require('./commands'); + +/** + * DetailView component implements a list of record layouts. + */ +function DetailView(gristDoc, viewSectionModel) { + BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true }); + + this.viewFields = gristDoc.docModel.viewFields; + this._isSingle = (this.viewSection.parentKey.peek() === 'single'); + + //-------------------------------------------------- + // Create and attach the DOM for the view. + this.recordLayout = this.autoDispose(RecordLayout.create({ + viewSection: this.viewSection, + buildFieldDom: this.buildFieldDom.bind(this), + resizeCallback: () => { + if (!this._isSingle) { + this.scrolly().updateSize(); + // Keep the cursor in view if the scrolly height resets. + // TODO: Ideally the original position should be kept in scroll view. + this.scrolly().scrollRowIntoView(this.cursor.rowIndex.peek()); + } + } + })); + + this.scrolly = this.autoDispose(ko.computed(() => { + if (!this.recordLayout.isEditingLayout() && !this._isSingle) { + return koDomScrolly.getInstance(this.viewData); + } + })); + + // Reset scrolly heights when record theme changes, since it affects heights. + this.autoDispose(this.viewSection.themeDef.subscribe(() => { + var scrolly = this.scrolly(); + if (scrolly) { + setTimeout(function() { scrolly.resetHeights(); }, 0); + } + })); + + this.layoutBoxIdx = ko.observable(0); + + //-------------------------------------------------- + if (this._isSingle) { + this.detailRecord = this.autoDispose(this.tableModel.createFloatingRowModel()); + this._updateFloatingRow(); + this.autoDispose(this.cursor.rowIndex.subscribe(this._updateFloatingRow, this)); + this.autoDispose(this.viewData.subscribe(this._updateFloatingRow, this)); + } else { + this.detailRecord = null; + } + + //-------------------------------------------------- + // Construct DOM + this.viewPane = this.autoDispose(this.buildDom()); + + //-------------------------------------------------- + // Set up DOM event handling. + + // Clicking on a detail field selects that field. + this.onEvent(this.viewPane, 'mousedown', '.g_record_detail_el', function(elem, event) { + this.viewSection.hasFocus(true); + var rowModel = this.recordLayout.getContainingRow(elem, this.viewPane); + var field = this.recordLayout.getContainingField(elem, this.viewPane); + commands.allCommands.setCursor.run(rowModel, field); + }); + + // Double-clicking on a field also starts editing the field. + this.onEvent(this.viewPane, 'dblclick', '.g_record_detail_el', function(elem, event) { + this.activateEditorAtCursor(); + }); + + //-------------------------------------------------- + // Instantiate CommandGroups for the different modes. + this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus)); + this.newFieldCommandGroup = this.autoDispose( + commands.createGroup(DetailView.newFieldCommands, this, this.isNewFieldActive)); +} +Base.setBaseFor(DetailView); +_.extend(DetailView.prototype, BaseView.prototype); + + +DetailView.prototype.onTableLoaded = function() { + BaseView.prototype.onTableLoaded.call(this); + this._updateFloatingRow(); + + const scrolly = this.scrolly(); + if (scrolly) { + scrolly.scrollToSavedPos(this.viewSection.lastScrollPos); + } +}; + +DetailView.prototype._updateFloatingRow = function() { + if (this.detailRecord) { + this.viewData.setFloatingRowModel(this.detailRecord, this.cursor.rowIndex.peek()); + } +}; + +/** + * DetailView commands. + */ +DetailView.generalCommands = { + cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); }, + cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); }, + pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); }, + pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); }, + + deleteRecords: function() { + // Do not allow deleting the add record row. + if (!this._isAddRow()) { + this.deleteRow(this.cursor.rowIndex()); + } + }, + + copy: function() { return this.copy(this.getSelection()); }, + cut: function() { return this.cut(this.getSelection()); }, + paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); }, + + editLayout: function() { + if (this.scrolly()) { + this.scrolly().scrollRowIntoView(this.cursor.rowIndex()); + } + this.recordLayout.editLayout(this.cursor.rowIndex()); + } +}; + +//---------------------------------------------------------------------- + +// TODO: Factor code duplicated with GridView for deleteRow, deleteColumn, +// insertDetailField out of the view modules + +DetailView.prototype.deleteRow = function(index) { + if (this.viewSection.disableAddRemoveRows()) { + return; + } + var action = ['RemoveRecord', this.viewData.getRowId(index)]; + return this.tableModel.sendTableAction(action) + .bind(this).then(function() { + this.cursor.rowIndex(index); + }); +}; + +/** + * Pastes the provided data at the current cursor. + * + * @param {Array} data - Array of arrays of data to be pasted. Each array represents a row. + * i.e. [["1-1", "1-2", "1-3"], + * ["2-1", "2-2", "2-3"]] + * @param {Function} cutCallback - If provided returns the record removal action needed + * for a cut. + */ +DetailView.prototype.paste = function(data, cutCallback) { + let pasteData = data[0][0]; + let col = this.currentColumn(); + let isCompletePaste = (data.length === 1 && data[0].length === 1); + + let richData = this._parsePasteForView([[pasteData]], [col]); + if (_.isEmpty(richData)) { + return; + } + + // Array containing the paste action to which the cut action will be added if it exists. + const rowId = this.viewData.getRowId(this.cursor.rowIndex()); + const action = (rowId === 'new') ? ['BulkAddRecord', [null], richData] : + ['BulkUpdateRecord', [rowId], richData]; + const cursorPos = this.cursor.getCursorPos(); + + return this.sendPasteActions(isCompletePaste ? cutCallback : null, + this.prepTableActions([action])) + .then(results => { + // If a row was added, get its rowId from the action results. + const addRowId = (action[0] === 'BulkAddRecord' ? results[0][0] : null); + // Restore the cursor to the right rowId, even if it jumped. + this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowId : cursorPos.rowId}); + this.copySelection(null); + }); +}; + +/** + * Returns a selection of the selected rows and cols. In the case of DetailView this will just + * be one row and one column as multiple cell selection is not supported. + * + * @returns {Object} CopySelection + */ +DetailView.prototype.getSelection = function() { + return new CopySelection( + this.tableModel.tableData, + [this.viewData.getRowId(this.cursor.rowIndex())], + [this.viewSection.viewFields().at(this.cursor.fieldIndex())], + {} + ); +}; + +/** + * Builds the DOM for the given field of the given row. + * @param {MetaRowModel|String} field: Model for the field to render. For a new field being added, + * this may instead be an object with {isNewField:true, colRef, label, value}. + * @param {DataRowModel} row: The record of data from which to render the given field. + */ +DetailView.prototype.buildFieldDom = function(field, row) { + var self = this; + if (field.isNewField) { + return dom('div.g_record_detail_el.flexitem', + kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }), + dom('div.g_record_detail_label', field.label), + dom('div.g_record_detail_value', field.value) + ); + } + + var isCellSelected = ko.pureComputed(function() { + return this.cursor.fieldIndex() === (field && field._index()) && + this.cursor.rowIndex() === (row && row._index()); + }, this); + var isCellActive = ko.pureComputed(function() { + return this.viewSection.hasFocus() && isCellSelected(); + }, this); + + // Whether the cell is part of an active copy-paste operation. + var isCopyActive = ko.computed(function() { + return self.copySelection() && + self.copySelection().isCellSelected(row.getRowId(), field.colId()); + }); + + this.autoDispose(isCellSelected.subscribe(yesNo => { + if (yesNo) { + var layoutBox = dom.findAncestor(fieldDom, '.layout_hbox'); + this.layoutBoxIdx(_.indexOf(layoutBox.parentElement.childNodes, layoutBox)); + } + })); + var fieldBuilder = this.fieldBuilders.at(field._index()); + var fieldDom = dom('div.g_record_detail_el.flexitem', + dom.autoDispose(isCellSelected), + dom.autoDispose(isCellActive), + kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }), + dom('div.g_record_detail_label', kd.text(field.displayLabel)), + dom('div.g_record_detail_value', + kd.toggleClass('scissors', isCopyActive), + kd.toggleClass('record-add', row._isAddRow), + dom.autoDispose(isCopyActive), + fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected) + ) + ); + return fieldDom; +}; + +DetailView.prototype.buildDom = function() { + return dom('div.flexvbox.flexitem', + // Add .detailview_single when showing a single card or while editing layout. + kd.toggleClass('detailview_single', + () => this._isSingle || this.recordLayout.isEditingLayout()), + kd.maybe(this.recordLayout.isEditingLayout, () => { + const rowId = this.viewData.getRowId(this.recordLayout.editIndex.peek()); + const record = this.getRenderedRowModel(rowId); + return dom( + this.recordLayout.buildLayoutDom(record, true), + kd.cssClass(() => 'detail_theme_record_' + this.viewSection.themeDef()), + kd.cssClass('detailview_record_' + this.viewSection.parentKey.peek()), + ); + }), + kd.maybe(() => !this.recordLayout.isEditingLayout(), () => { + if (!this._isSingle) { + return dom('div.detailview_scroll_pane.flexitem', + kd.scrollChildIntoView(this.cursor.rowIndex), + dom.onDispose(() => { + // Save the previous scroll values to the section. + if (this.scrolly()) { + this.viewSection.lastScrollPos = this.scrolly().getScrollPos(); + } + }), + koDomScrolly.scrolly(this.viewData, {fitToWidth: true}, + row => this.makeRecord(row)), + ); + } else { + return dom( + this.makeRecord(this.detailRecord), + kd.domData('itemModel', this.detailRecord), + kd.hide(() => this.cursor.rowIndex() === null) + ); + } + }), + ); +}; + +/** @inheritdoc */ +DetailView.prototype.buildTitleControls = function() { + // Hide controls if this is a card list section, or if the section has a scroll cursor link, since + // the controls can be confusing in this case. + // Note that the controls should still be visible with a filter link. + const showControls = ko.computed(() => + this._isSingle && + (!this.viewSection.activeLinkSrcSectionRef() || this.viewSection.activeLinkTargetColRef()) && + !this.recordLayout.layoutEditor() + ); + return dom('div', + dom.autoDispose(showControls), + + kd.toggleClass('record-layout-editor', this.recordLayout.layoutEditor), + kd.maybe(this.recordLayout.layoutEditor, (editor) => editor.buildEditorDom()), + + kd.maybe(showControls, () => dom('div.grist-single-record__menu.flexhbox.flexnone', + this.gristDoc.app.addNewUIClass(), + dom('div.grist-single-record__menu__count.flexitem', + // Total should not include the add record row + kd.text(() => this._isAddRow() ? 'Add record' : + `${this.cursor.rowIndex() + 1} of ${this.getLastDataRowIndex() + 1}`) + ), + dom('div.btn-group.btn-group-xs', + dom('div.btn.btn-default.detail-left', + dom('span.glyphicon.glyphicon-chevron-left'), + dom.on('click', () => { this.cursor.rowIndex(this.cursor.rowIndex() - 1); }), + kd.toggleClass('disabled', () => this.cursor.rowIndex() === 0) + ), + dom('div.btn.btn-default.detail-right', + dom('span.glyphicon.glyphicon-chevron-right'), + dom.on('click', () => { this.cursor.rowIndex(this.cursor.rowIndex() + 1); }), + kd.toggleClass('disabled', () => this.cursor.rowIndex() >= this.viewData.all().length - 1) + ) + ), + dom('div.btn-group.btn-group-xs.detail-add-grp', + dom('div.btn.btn-default.detail-add-btn', + dom('span.glyphicon.glyphicon-plus'), + dom.on('click', () => { + let addRowIndex = this.viewData.getRowIndex('new'); + this.cursor.rowIndex(addRowIndex); + }), + kd.toggleClass('disabled', () => this.viewData.getRowId(this.cursor.rowIndex()) === 'new') + ) + ) + )) + ); +}; + + +/** @inheritdoc */ +DetailView.prototype.onResize = function() { + var scrolly = this.scrolly(); + if (scrolly) { + scrolly.scheduleUpdateSize(); + } +}; + +/** @inheritdoc */ +DetailView.prototype.onRowResize = function(rowModels) { + var scrolly = this.scrolly(); + if (scrolly) { + scrolly.resetItemHeights(rowModels); + } +}; + +DetailView.prototype.makeRecord = function(record) { + return dom( + this.recordLayout.buildLayoutDom(record), + kd.cssClass(() => 'detail_theme_record_' + this.viewSection.themeDef()), + kd.toggleClass('active', () => (this.cursor.rowIndex() === record._index() && this.viewSection.hasFocus())), + // 'detailview_record_single' or 'detailview_record_detail' doesn't need to be an observable, + // since a change to parentKey would cause a separate call to makeRecord. + kd.cssClass('detailview_record_' + this.viewSection.parentKey.peek()) + ); +}; + +/** + * Extends BaseView getRenderedRowModel. Called to obtain the rowModel for the given rowId. + * Returns the rowModel if it is rendered in the current view type, otherwise returns null. + */ +DetailView.prototype.getRenderedRowModel = function(rowId) { + if (this.detailRecord) { + return this.detailRecord.getRowId() === rowId ? this.detailRecord : null; + } else { + return this.viewData.getRowModel(rowId); + } +}; + +/** + * Returns a boolean indicating whether the given index is the index of the add row. + * Index defaults to the current index of the cursor. + */ +DetailView.prototype._isAddRow = function(index = this.cursor.rowIndex()) { + return this.viewData.getRowId(index) === 'new'; +}; + +module.exports = DetailView; diff --git a/app/client/components/DocComm.ts b/app/client/components/DocComm.ts new file mode 100644 index 00000000..70da10d2 --- /dev/null +++ b/app/client/components/DocComm.ts @@ -0,0 +1,204 @@ +import {Comm, CommMessage} from 'app/client/components/Comm'; +import {reportError, UserError} from 'app/client/models/errors'; +import {Notifier} from 'app/client/models/NotifyModel'; +import {ActionGroup} from 'app/common/ActionGroup'; +import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI'; +import {DocAction, UserAction} from 'app/common/DocActions'; +import {OpenLocalDocResult} from 'app/common/DocListAPI'; +import {docUrl} from 'app/common/urlUtils'; +import {Events as BackboneEvents} from 'backbone'; +import {Disposable, Emitter} from 'grainjs'; + +// tslint:disable:no-console + +export interface DocUserAction extends CommMessage { + docFD: number; + fromSelf?: boolean; + data: { + docActions: DocAction[]; + actionGroup: ActionGroup; + }; +} + +const SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only + +/** + * The type of data.methods object created by openDoc() in app/client/components/Comm.js. + * This is used in much of client-side code, and exposed firstly as GristDoc.docComm. + */ +export class DocComm extends Disposable implements ActiveDocAPI { + // These are all the methods of ActiveDocAPI. Listing them explicitly lets typescript verify + // that we haven't missed any. + // closeDoc has a special implementation below. + public fetchTable = this._wrapMethod("fetchTable"); + public fetchTableSchema = this._wrapMethod("fetchTableSchema"); + public useQuerySet = this._wrapMethod("useQuerySet"); + public disposeQuerySet = this._wrapMethod("disposeQuerySet"); + // applyUserActions has a special implementation below. + public applyUserActionsById = this._wrapMethod("applyUserActionsById"); + public importFiles = this._wrapMethod("importFiles"); + public finishImportFiles = this._wrapMethod("finishImportFiles"); + public cancelImportFiles = this._wrapMethod("cancelImportFiles"); + public addAttachments = this._wrapMethod("addAttachments"); + public findColFromValues = this._wrapMethod("findColFromValues"); + public getFormulaError = this._wrapMethod("getFormulaError"); + public fetchURL = this._wrapMethod("fetchURL"); + public autocomplete = this._wrapMethod("autocomplete"); + public shareDoc = this._wrapMethod("shareDoc"); + public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc"); + public getActionSummaries = this._wrapMethod("getActionSummaries"); + public startBundleUserActions = this._wrapMethod("startBundleUserActions"); + public stopBundleUserActions = this._wrapMethod("stopBundleUserActions"); + public forwardPluginRpc = this._wrapMethod("forwardPluginRpc"); + public reloadPlugins = this._wrapMethod("reloadPlugins"); + public reloadDoc = this._wrapMethod("reloadDoc"); + public fork = this._wrapMethod("fork"); + + public changeUrlIdEmitter = this.autoDispose(new Emitter()); + + // We save the clientId that was used when opening the doc. If it changes (e.g. reconnecting to + // another server), it would be incorrect to use the new clientId without re-opening the doc + // (which is handled by App.ts). This way, Comm can protect against mismatched clientIds. + private _clientId: string; + private _docFD: number; + private _forkPromise: Promise|null = null; + private _isClosed: boolean = false; + private listenTo: BackboneEvents['listenTo']; // set by Backbone + + constructor(private _comm: Comm, openResponse: OpenLocalDocResult, private _docId: string, + private _notifier: Notifier) { + super(); + this._setOpenResponse(openResponse); + // If *this* doc is shutdown forcibly (e.g. via reloadDoc call), mark it as closed, so we + // don't attempt to close it again. + this.listenTo(_comm, 'docShutdown', (m: CommMessage) => { + if (this.isActionFromThisDoc(m)) { this._isClosed = true; } + }); + this.onDispose(() => this._shutdown()); + } + + // Returns the URL params that identifying this open document to the DocWorker + // (used e.g. in attachment and download URLs). + public getUrlParams(): {clientId: string, docFD: number} { + return { clientId: this._clientId, docFD: this._docFD }; + } + + // Completes a path by adding the correct worker host and prefix for this document. + // E.g. "/uploads" becomes "https://host.name/v/ver/o/org/uploads" + public docUrl(path: string) { + return docUrl(this.docWorkerUrl, path); + } + + // Returns a base url to the worker serving the current document, e.g. + // "https://host.name/v/ver/" + public get docWorkerUrl() { + return this._comm.getDocWorkerUrl(this._docId); + } + + // Returns whether a message received by this Comm object is for the current doc. + public isActionFromThisDoc(message: CommMessage): boolean { + return message.docFD === this._docFD; + } + + /** + * Overrides applyUserActions() method to also add the UserActions to a list, for use in tests. + */ + public applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise { + this._comm.addUserActions(actions); + return this._callMethod('applyUserActions', actions, options); + } + + /** + * Overrides closeDoc() method to call to Comm directly, without triggering forking logic. + * This is important in particular since it may be called while forking. + */ + public closeDoc(): Promise { + return this._callDocMethod('closeDoc'); + } + + /** + * Forks the document, making sure the url gets updated, and holding any actions + * until the fork is complete. If a fork has already been started/completed, this + * does nothing. + */ + public async forkAndUpdateUrl(): Promise { + await (this._forkPromise || (this._forkPromise = this._doForkDoc())); + } + + // Clean up connection after closing doc. + private async _shutdown() { + console.log(`DocComm: shutdown clientId ${this._clientId} docFD ${this._docFD}`); + try { + // Close the document to unsubscribe from further updates on it. + if (!this._isClosed) { + await this.closeDoc(); + } + } catch (err) { + console.warn(`DocComm: closeDoc failed: ${err}`); + } finally { + if (!this._comm.isDisposed()) { + this._comm.releaseDocConnection(this._docId); + } + } + } + + /** + * Store important information from the response to openDoc, and + * ensure we have a connection to a docWorker for the document + * identified by the current docId. the caller of _setOpenResponse + * should call _releaseDocConnection for any previous docId. + */ + private _setOpenResponse(openResponse: OpenLocalDocResult) { + this._docFD = openResponse.docFD; + this._clientId = openResponse.clientId; + this._comm.useDocConnection(this._docId); + } + + private _wrapMethod(name: Name): ActiveDocAPI[Name] { + return this._callMethod.bind(this, name); + } + + private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { + return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS); + } + + private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { + if (this._forkPromise) { + // If a fork is pending or has finished, call the method after waiting for it. + // (If we've gone through a fork, we will not consider forking again.) + await this._forkPromise; + return this._callDocMethod(name, ...args); + } + try { + return await this._callDocMethod(name, ...args); + } catch (err) { + // TODO should be the suggested fork id and fork user. + if (err.shouldFork) { + // If the server suggests to fork, do it now, or wait for the fork already pending. + await this.forkAndUpdateUrl(); + return this._callDocMethod(name, ...args); + } + throw err; + } + } + + private _callDocMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { + return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args); + } + + private async _doForkDoc(): Promise { + reportError(new UserError('Preparing your copy...', {key: 'forking'})); + const {urlId, docId} = await this.fork(); + const openResponse = await this._comm.openDoc(docId); + // Close the old doc and release the old connection. Note that the closeDoc call is expected + // to fail, since we close the websocket immediately after it. So let it fail silently. + this.closeDoc().catch(() => null); + this._comm.releaseDocConnection(this._docId); + this._docId = docId; + this._setOpenResponse(openResponse); + this.changeUrlIdEmitter.emit(urlId); + reportError(new UserError('You are now editing your own copy', {key: 'forking'})); + } +} + +Object.assign(DocComm.prototype, BackboneEvents); diff --git a/app/client/components/DocConfigTab.js b/app/client/components/DocConfigTab.js new file mode 100644 index 00000000..62a5b6cf --- /dev/null +++ b/app/client/components/DocConfigTab.js @@ -0,0 +1,34 @@ +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var ValidationPanel = require('./ValidationPanel'); + +/** + * Document level configuration settings. + * @param {Object} options.gristDoc A reference to the GristDoc object + * @param {Function} docName A knockout observable containing a String + */ +function DocConfigTab(options, docName) { + this.gristDoc = options.gristDoc; + + // Panel to configure validation rules. + this.validationPanel = this.autoDispose(ValidationPanel.create({gristDoc: this.gristDoc})); + + this.autoDispose( + this.gristDoc.addOptionsTab( + 'Validate Data', + dom('span.glyphicon.glyphicon-check'), + this.buildValidationsConfigDomObj(), + { 'shortLabel': 'Valid' } + ) + ); +} +dispose.makeDisposable(DocConfigTab); + +DocConfigTab.prototype.buildValidationsConfigDomObj = function() { + return [{ + 'buildDom': this.validationPanel.buildDom.bind(this.validationPanel), + 'keywords': ['document', 'validations', 'rules', 'validate'] + }]; +}; + +module.exports = DocConfigTab; diff --git a/app/client/components/DocList.css b/app/client/components/DocList.css new file mode 100644 index 00000000..0f076106 --- /dev/null +++ b/app/client/components/DocList.css @@ -0,0 +1,302 @@ +#grist-app { + background: url('img/gplaypattern.png'); +} + +.start-doclist { + position: relative; + + margin: 0 auto; + padding: 0; + + top: var(--layout-top-spacer); + height: calc(100% - 2*var(--layout-top-spacer)); + + width: 90vw; + min-width: 600px; + max-width: 900px; + + background-color: var(--color-doclist-bg); + box-shadow: 0 1px 2px 0 rgba(0,0,0,0.5); + + overflow: hidden; +} + +.header-actions { + background-color: var(--color-logo-bg); + text-align: center; +} + +.header-actions-logo { + height: var(--layout-doclist-header-height); + padding: .5rem; + + background: url('img/logo-grist-transp.png'); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + + cursor: pointer; +} + +.footer-actions { + margin: 1rem auto; + text-align: center; +} + +.header-doclist { + height: var(--layout-doclist-header-height); + min-height: var(--layout-doclist-header-height); + + background: linear-gradient(to bottom, #f9f9f9, #f0f0f0); + + align-items: center; +} + +.doclist-link { + margin: .5rem; + font-weight: 600; + color: var(--color-header-doclist); +} + +.doclist-section { + height: 100%; +} + +.section-sidepane-wrapper { + width: 180px; + min-width: 180px; + + border-right: 1px solid var(--color-border-light); +} + +.section-sidepane { + margin: .5rem auto; + padding: 0; + + text-align: center; + align-items: center; +} + +.section-sidepane.b-actions { + margin: .5rem 2rem; + align-items: stretch; +} + +.section-doclist { + height: var(--layout-doclist-height); +} + +.doclist-body-wrapper { + margin-top: 2px; + height: calc(100% - 2px - var(--layout-doclist-header-height)); + width: 100%; + overflow: auto; +} + +.g-btn.mod-doclist__sidepane { + border-style: solid; + border-width: 1px 1px 1px 4px; + + font-weight: 400; + color: #000; + height: 30px; + line-height: 18px; +} +.g-btn.mod-doclist__sidepane:hover { + font-weight: 600; + color: #fff; +} + +.g-btn.mod-doclist__sidepane.fit-text { + font-size: 1.0rem; +} + +.g-btn.mod-doclist__sidepane.el-login { + border-color: var(--color-btn-login); + background-color: var(--color-btn-login-background); +} +.g-btn.mod-doclist__sidepane.el-login:hover { + background-color: var(--color-btn-login); +} + +.g-btn.mod-doclist__sidepane.el-createdoc { + border-color: var(--color-btn-createdoc); +} +.g-btn.mod-doclist__sidepane.el-createdoc:hover { + background-color: var(--color-btn-createdoc); +} + +.g-btn.mod-doclist__sidepane.el-editteam { + margin-top: 1rem; + border-color: #7e25ff; +} +.g-btn.mod-doclist__sidepane.el-editteam:hover { + background-color: #7e25ff; +} + +.g-btn.mod-doclist__sidepane.el-uploaddoc { + position: relative; + margin-top: 1rem; + + border-color: var(--color-btn-uploaddoc); +} +.g-btn.mod-doclist__sidepane.el-uploaddoc:hover { + background-color: var(--color-btn-uploaddoc); +} + +.g-btn.mod-doclist__invite { + width: 20px; + height: 20px; + padding: 4px; +} + +.doclist-invite { + display: flex; + flex-direction: column; +} +.doclist-invite-bottom-row { + display: inline-block; + font-size: 1.0rem; + color: var(--color-hint-text); +} + +.mod-doclist__sidepane.el-divider { + border-bottom: 1px solid var(--color-border-light); + height: 1px; + width: 90%; + margin: 15px auto; +} + +.el-uploaddoc__bar { + position: absolute; + height: 100%; + width: 0%; + left: 0px; + top: 0px; + padding-top: 1px; + + color: #fff; + background-color: var(--color-btn-uploaddoc); + + font-size: 2rem; + + transition: width .2s ease-out, opacity .2s; +} + +.g-btn { + margin: 0; + padding: .5rem .8rem; + + cursor: pointer; +} + +.g-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +.doclist-logo-img { + width: 90px; +} + +.doclist-header { + top: 0; + height: var(--layout-doclist-header-height); + min-height: var(--layout-doclist-header-height); + + padding: .2rem 1rem; + align-items: center; + + background: linear-gradient(to bottom, #f0f0f0, #e6e6e6); + box-shadow: 0 2px 2px -1px rgba(0,0,0,0.5); + + text-decoration: underline; +} + +.doclist-row { + padding: 1rem; + border-bottom: 1px solid var(--color-border-light); + + transition: background-color .2s; +} + +.doclist-row:hover { + background-color: var(--color-list-row-hover); +} + +.doclist-sample { + color: var(--color-header-doclist); +} + +.doclist-column { + min-width: 120px; + cursor: pointer; +} + +.doclist-column.doclist-column__name { + min-width: 160px; +} + +.doclist-column.doclist-column__download, .doclist-column.doclist-column__delete { + min-width: 15px; + color: #777; + font-size: 1.0rem; + text-align: right; + margin-left: 10px; + padding: 3px; +} + +.doclist-column.mod-header { + padding-left: 1.6rem; +} + +.doclist-column__size { + text-align: right; + padding-right: 1rem; +} + +.doclist-download_link { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.glyphicon.mod-doclist-column { + padding-right: .5rem; +} + +div.uploadAlert { + position: relative; + margin-bottom: 1rem; +} + +.gupload { + margin-bottom: 1rem; +} + +.gupload_item { + position: relative; + background-color: transparent; +} + +.gupload_bar { + position: absolute; + z-index: -1; + left: 0; + top: 0; + height: 100%; + background-color: lightgreen; +} + +.gupload_failed { + background-color: #FFA0A0; +} + +.gupload_info { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/client/components/DocList.js b/app/client/components/DocList.js new file mode 100644 index 00000000..a898672c --- /dev/null +++ b/app/client/components/DocList.js @@ -0,0 +1,331 @@ +/* global window, $ */ + +const bluebird = require('bluebird'); +const _ = require('underscore'); +const ko = require('knockout'); +const BackboneEvents = require('backbone').Events; + +const gutil = require('app/common/gutil'); +const version = require('app/common/version'); + +const dispose = require('../lib/dispose'); +const dom = require('../lib/dom'); +const kd = require('../lib/koDom'); +const koSession = require('../lib/koSession'); +const {IMPORTABLE_EXTENSIONS, selectFiles} = require('../lib/uploads'); + +const commands = require('./commands'); +const {urlState} = require('app/client/models/gristUrlState'); +const {showConfirmDialog} = require('./Confirm'); + +const sortByFuncs = { + 'name': (a, b) => a.localeCompare(b), + 'mtime': (a, b) => gutil.nativeCompare(a, b), + 'size': (a, b) => gutil.nativeCompare(a, b) +}; + +function DocList(app) { + this.app = app; + this.docListModel = app.docListModel; + this.docs = app.docListModel.docs; + this._docInvites = app.docListModel.docInvites; + + // Default sort is by modified time in descending order + // TODO: This would be better yet as a user preference, since this still resets to default for + // each browser window. + this.sortBy = koSession.sessionValue('docListSortBy', 'mtime'); + this.sortAsc = koSession.sessionValue('docListSortDir', -1); // 1 for asc, -1 for desc + + let compareFunc = this.autoDispose(ko.computed(() => { + let attrib = this.sortBy(); + let asc = this.sortAsc(); + let compareFunc = sortByFuncs[attrib]; + return (a, b) => gutil.nativeCompare(a.tag, b.tag) || compareFunc(a[attrib], b[attrib]) * asc; + })); + this.sortedDocs = this.autoDispose(ko.computed(() => + this.docs.all().sort(compareFunc())).extend({rateLimit: 0})); + + this.docListModel.refreshDocList(); + + this.autoDispose(this.app.login.isLoggedIn.subscribe(loggedIn => { + // Refresh the doc list to show/hide docs and invites belonging to the logged in user. + this.docListModel.refreshDocList(); + })); +} +dispose.makeDisposable(DocList); +_.extend(DocList.prototype, BackboneEvents); + +// TODO: Factor out common code from DocList and NavBar (e.g. status indicator) +DocList.prototype.buildDom = function() { + let currentStatus = ko.observable('OK'); + + this.listenTo(this.app.comm, 'connectionStatus', (message, status) => { + console.warn('connectionStatus', message, status); + currentStatus(status); + }); + + return this.autoDispose( + dom('div.start-doclist.flexhbox', + dom('section.doclist-section.section-sidepane-wrapper.flexvbox', + parts.sidepane.header(() => this.docListModel.refreshDocList()), + parts.sidepane.connection(currentStatus), + dom('div.section-sidepane.b-actions.flexnone.flexvbox', + kd.maybe(() => this.app.features().signin, () => + dom('div', + parts.sidepane.loginButton(this.app.login), + dom('div.mod-doclist__sidepane.el-divider') + ) + ), + parts.sidepane.createDocButton(this.createNewDoc.bind(this)), + window.electronOpenDialog ? parts.sidepane.openDocButton() : null, + parts.sidepane.uploadDocButton(this.uploadNewDoc.bind(this)) + ), + dom('div.flexitem'), + parts.sidepane.footer(commands.allCommands.help.run)), + dom('section.doclist-section.section-doclist-wrapper.flexitem', this._buildDoclistBody()))); +}; + +DocList.prototype._buildDoclistBody = function() { + return dom('div.section-doclist', + dom('div.doclist-header.flexhbox', + dom('div.doclist-column.doclist-column__name.mod-header.flexitem', 'Name', + parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'name'), + dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'name'))), + dom('div.doclist-column.doclist-column__size', 'Size', + parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'size'), + dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'size'))), + dom('div.doclist-column.doclist-column__modified', 'Modified', + parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'mtime'), + dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'mtime'))), + dom('div.doclist-column.doclist-column__delete') + ), + dom('div.doclist-body-wrapper', + dom('div.doclist-body', + kd.scope(this.sortedDocs, docs => + docs.map(docObj => parts.doclist.listDoc(docObj, this._onClickItem.bind(this), + this._confirmRemoveDoc.bind(this)))), + kd.foreach(this._docInvites, inviteObj => + parts.doclist.listInvite(inviteObj, this._downloadSharedDoc.bind(this), + this._confirmDeclineInvite.bind(this)))))); +}; + +const parts = { + + sidepane: { + + connection: (statusObs) => + dom('div.section-sidepane.b-status.flexhbox', + dom('div.gnotifier', kd.cssClass(() => 'g-status-' + statusObs())), + dom('span', 'Connection: '), + kd.text(statusObs)), + + header: (headerCommand) => + dom('div.header-actions', + dom('div.header-actions-logo', // Logo set in css + {title: `Ver ${version.version} (${version.gitcommit})`}, + dom.on('click', headerCommand))), + + footer: (helpCommand) => + dom('div.footer-actions.flexvbox', + dom('a.doclist-link', { href: 'help', target: '_blank' }, 'Help'), + dom('a.doclist-link', { href: 'help/contact-us', target: '_blank'}, 'Contact'), + dom('a.doclist-link', { href: 'https://www.getgrist.com', target: '_blank'}, 'About')), + + loginButton: (login) => + dom('div.g-btn.mod-doclist__sidepane.el-login', + dom.testId('DocList_login'), + kd.toggleClass('fit-text', () => login.isLoggedIn()), + kd.text(login.buttonText), + dom.on('click', e => { login.onClick(e); })), + + createDocButton: createNewDoc => + dom('div.g-btn.mod-doclist__sidepane.el-createdoc', + dom.testId('DocList_createDoc'), + 'Create document', + dom.on('click', createNewDoc)), + + uploadDocButton: uploadNewDoc => { + let uploadStatusIcons = { + 'uploading': 'glyphicon-upload', + 'success': 'glyphicon-ok-sign', + 'error': 'glyphicon-exclamation-sign' + }; + let uploadStatus = ko.observable(null); + + let uploadBar; + let uploadProgress = percentage => uploadBar.style.width = percentage + '%'; + + return dom('div.g-btn.mod-doclist__sidepane.el-uploaddoc', + dom.testId('DocList_uploadBtn'), + uploadBar = dom('div.el-uploaddoc__bar', + kd.show(uploadStatus), + dom('span.glyphicon.glyphicon-upload', + kd.cssClass(() => uploadStatusIcons[uploadStatus()]) + ) + ), + dom('span', + kd.style('visibility', () => uploadStatus() === null ? 'visible' : 'hidden'), + 'Import document' + ), + dom.on('click', () => uploadNewDoc(uploadStatus, uploadProgress)) + ); + }, + + openDocButton: () => dom('div.g-btn.mod-doclist__sidepane.el-uploaddoc', + dom('span', 'Open document'), + dom.on('click', window.electronOpenDialog) + ), + }, + + doclist: { + + ascDescArrow: (sortBy, sortAsc, attrib) => + dom('span', kd.text(() => sortBy() === attrib ? (sortAsc() > 0 ? '▲' : '▼') : '')), + + listDoc: (docObj, onClick, onDelete) => { + let mtime = new Date(docObj.mtime); + return dom('div.doclist-row.flexhbox', + kd.toggleClass('doclist-sample', docObj.tag === 'sample'), + dom('div.doclist-column.doclist-column__name.flexitem', + dom('span.glyphicon.glyphicon-file.mod-doclist-column'), + dom('a', { + href: urlState().makeUrl({doc: docObj.name}) + }, + kd.toggleClass('doclist-sample', docObj.tag === 'sample'), + docObj.tag ? `[${gutil.capitalize(docObj.tag)}] ` : null, + docObj.name, + dom.on('click', (ev) => onClick(ev, docObj)) + ) + ), + dom('div.doclist-column.doclist-column__size', gutil.byteString(docObj.size)), + dom('div.doclist-column.doclist-column__modified', + mtime.toLocaleDateString() + ' ' + mtime.toLocaleTimeString()), + // Show a downlink link for each file in the hosted version, but not in the electron version. + window.isRunningUnderElectron ? null : dom('div.doclist-column.doclist-column__download.glyphicon.glyphicon-download-alt', + dom('a.doclist-download_link', { + href: 'download?' + $.param({doc: docObj.name}), + download: `${docObj.name}.grist` + }) + ), + dom('div.doclist-column.doclist-column__delete.glyphicon.glyphicon-trash', + dom.on('click', () => onDelete(docObj.name)))); + }, + + listInvite: (docObj, onDownload, onDecline) => { + const name = docObj.senderName; + const email = docObj.senderEmail; + return dom('div.doclist-row.doclist-invite.flexhbox', + dom('div.flexhbox.doclist-invite-top-row', + dom('div.doclist-column.doclist-column__name.flexitem', + dom('span.glyphicon.glyphicon-download-alt.mod-doclist-column'), + dom.on('click', () => onDownload(docObj)), + '[Invite] ', + docObj.name + ), + dom('div.doclist-column.doclist-column__size'), + dom('div.doclist-column.doclist-column__modified'), + dom('div.doclist-column.doclist-column__delete.glyphicon.glyphicon-remove', + dom.on('click', () => onDecline(docObj))) + ), + dom('div.flexhbox.doclist-invite-bottom-row', 'Sent by: ' + (name ? `${name} (${email})` : email)) + ); + } + } +}; + +DocList.prototype._downloadSharedDoc = function(docObj) { + return this.app.comm.downloadSharedDoc(docObj.docId, docObj.name) + .then(() => this.docListModel.refreshDocList()); +}; + +DocList.prototype._confirmDeclineInvite = function(docObj) { + showConfirmDialog(`Ignore invite to ${docObj.name}?`, 'Ignore', () => this._ignoreInvite(docObj)); +}; + +DocList.prototype._confirmRemoveDoc = function(docName) { + if (window.isRunningUnderElectron) { + showConfirmDialog(`Move ${docName} to trash?`, 'Move to trash', () => this.app.comm.deleteDoc(docName, false)); + } else { + showConfirmDialog(`Delete ${docName} permanently?`, 'Delete', () => this.app.comm.deleteDoc(docName, true)); + } +}; + +DocList.prototype._ignoreInvite = function(docObj) { + return this.app.comm.ignoreLocalInvite(docObj.docId) + .then(() => this.docListModel.refreshDocList()); +}; + +// commands useable while title is active +DocList.fileEditorCommands = { + accept: function() { this.finishEditingFileName(true); }, + cancel: function() { this.finishEditingFileName(false); }, +}; + +DocList.prototype.createNewDoc = function(source, event) { + return this.app.comm.createNewDoc() + .then(docName => urlState().pushUrl({doc: docName}, {avoidReload: true})) + .catch(err => console.error(err)); +}; + +DocList.prototype._onClickItem = function(ev, docObj) { + ev.preventDefault(); + if (docObj.tag === 'sample') { + // Note that we don't expect this to fail, so an error will show up in the NotifyBox. + this.app.comm.importSampleDoc(docObj.name) + .then(docName => urlState().pushUrl({doc: docName}, {avoidReload: true})); + return false; + } + // To avoid a page reload, we handle the change of url. Previously we used fragments, + // so the browser knew there was no page change going on. Now, we are using real urls + // and we have to hold the browser's hand. + urlState().pushUrl({doc: docObj.name}, {avoidReload: true}); + return false; +}; + +DocList.prototype.uploadNewDoc = function(uploadStatus, uploadProgress) { + function progress(percent) { + // We use one progress bar to combine the time to upload and the time to import in 40%-60% + // split. TODO The second part needs to be done; currently it stops at 40%, but at least the + // user can tell that there is still something to wait for. + const p = percent * 0.4; + uploadProgress(p); + if (p > 0) { uploadStatus('uploading'); } + } + return bluebird.try(() => { + return selectFiles({multiple: true, extensions: IMPORTABLE_EXTENSIONS}, + progress); + }) + .then(uploadResult => { + // Put together a text summary of what got uploaded, currently only for debugging. + const summaryList = uploadResult.files.map(f => `${f.origName} (${f.size})`); + console.log('Upload summary:', summaryList.join(", ")); + // TODO: This step should include its own progress callback, and the progress indicator should + // combine the two steps, e.g. in 40%-60% split. + return this.app.comm.importDoc(uploadResult.uploadId); + }) + .then(docName => { + uploadStatus('success'); + return bluebird.delay(500) // Let the user see the OK icon briefly. + .then(() => urlState().pushUrl({doc: docName}, {avoidReload: true})); + }) + .catch(err => { + console.error("Upload failed: %s", err); + uploadStatus('error'); + return bluebird.delay(2000); // Let the user see the error icon. + }) + .finally(() => { + uploadStatus(null); + }); +}; + +function updateSort(sortObs, sortAsc, sortValue) { + let currSort = sortObs(); + if (currSort === sortValue) { + sortAsc(-1 * sortAsc()); + } else { + sortObs(sortValue); + sortAsc(1); + } +} + +module.exports = DocList; diff --git a/app/client/components/EmbedForm.css b/app/client/components/EmbedForm.css new file mode 100644 index 00000000..d3bbe165 --- /dev/null +++ b/app/client/components/EmbedForm.css @@ -0,0 +1,39 @@ +.embed-form-desc { + margin: 10px 0; +} + +.embed-form-basket-id { + font-weight: bold; + margin-right: 5px; +} + +.embed-form-tables { + text-align: center; +} + +.embed-form-tables .kf_row > .kf_elem { + margin: 0; + width: 90%; +} + +.embed-form-table-id { + text-align: left; +} + +.embed-form-published { + background-color: #f0f9f9; + border: 1px dashed #35afae; + padding: 0 5px 5px 5px; +} + +.embed-form-unpublished { + padding: 5px; +} + +.embed-form-link { + text-align: center; +} + +.embed-form-connect { + text-align: center; +} diff --git a/app/client/components/EmbedForm.js b/app/client/components/EmbedForm.js new file mode 100644 index 00000000..d0ec5faa --- /dev/null +++ b/app/client/components/EmbedForm.js @@ -0,0 +1,205 @@ +// External dependencies +const _ = require('underscore'); +const ko = require('knockout'); +const BackboneEvents = require('backbone').Events; + +// Grist client libs +const dispose = require('../lib/dispose'); +const dom = require('../lib/dom'); +const kd = require('../lib/koDom'); +const kf = require('../lib/koForm'); +const ModalDialog = require('./ModalDialog'); +const gutil = require('app/common/gutil'); + +const BASE_URL = 'https://syvvdfor2a.execute-api.us-east-1.amazonaws.com/test'; + +/** + * EmbedForm - Handles logic and dom for the modal embedding instruction box. + */ +function EmbedForm(gristDoc) { + this._docComm = gristDoc.docComm; + this._login = gristDoc.app.login; + this._basketId = gristDoc.docInfo.basketId; + this._tableIds = gristDoc.docModel.allTableIds.peek().sort(); + + // Arrays of published and unpublished tables, initialized in this._refreshTables() + this._published = ko.observable([]); + this._unpublished = ko.observable([]); + + // Notify strings which are displayed to the user when set + this._errorNotify = ko.observable(); + this._updateNotify = ko.observable(); + + // The state of initialization, either 'connecting', 'failed', or 'done'. + this._initState = ko.observable('connecting'); + + this._embedDialog = this.autoDispose(ModalDialog.create({ + title: 'Upload for External Embedding', + body: this._buildEmbedDom(), + width: '420px' + })); + this._embedDialog.show(); + + this.listenTo(this._embedDialog, 'close', () => this.dispose()); + + // Perform the initial fetch to see which tables are published. + this._initFetch(); +} +_.extend(EmbedForm.prototype, BackboneEvents); +dispose.makeDisposable(EmbedForm); + +/** + * Performs the initial fetch to see which tables are published. + * Times out after 4 seconds, giving the user the option to retry. + */ +EmbedForm.prototype._initFetch = function() { + this._initState('connecting'); + return this._refreshTables() + .timeout(4000) + .then(() => { + this._initState('done'); + }) + .catch(err => { + console.error("EmbedForm._initFetch failed", err); + this._initState('failed'); + }); +}; + +/** + * Calls on basket to see which tables are published, then updates the published + * and unpublished local observables. + */ +EmbedForm.prototype._refreshTables = function() { + // Fetch the tables from the basket + return this._login.getBasketTables(this._docComm) + .then(basketTableIds => { + let published = []; + let unpublished = []; + gutil.sortedScan(this._tableIds, basketTableIds.sort(), (local, cloud) => { + let item = { + tableId: local || cloud, + local: Boolean(local), + cloud: Boolean(cloud) + }; + if (cloud) { + published.push(item); + } else { + unpublished.push(item); + } + }); + this._published(published); + this._unpublished(unpublished); + }); +}; + +/** + * Builds the part of the form showing the table names and their status, and + * the buttons to change their status. + */ +EmbedForm.prototype._buildTablesDom = function() { + return dom('div.embed-form-tables', + kd.scope(this._published, published => { + return published.length > 0 ? dom('div.embed-form-published', + dom('div.embed-form-desc', `Published to Basket (basketId: ${this._basketId()})`), + published.map(t => { + return kf.row( + 16, dom('a.embed-form-table-id', { href: this._getUrl(t.tableId), target: "_blank" }, + t.tableId), + 8, t.local ? this._makeButton('Update', t.tableId, 'update') : 'Only in Basket', + 1, dom('div'), + 2, this._makeButton('x', t.tableId, 'delete') + ); + }) + ) : null; + }), + dom('div.embed-form-unpublished', + kd.scope(this._unpublished, unpublished => { + return unpublished.map(t => { + return kf.row( + 16, dom('span.embed-form-table-id', t.tableId), + 8, this._makeButton('Publish', t.tableId, 'add'), + 3, dom('div') + ); + }); + }) + ) + ); +}; + +/** + * Builds the body of the table publishing modal form. + */ +EmbedForm.prototype._buildEmbedDom = function() { + // TODO: Include links to the npm page and to download basket-api.js. + return dom('div.embed-form', + kd.scope(this._initState, state => { + switch (state) { + case 'connecting': + return dom('div.embed-form-connect', 'Connecting...'); + case 'failed': + return dom('div', + dom('div.embed-form-connect', 'Connection to Basket failed'), + kf.buttonGroup( + kf.button(() => { + this._initFetch(); + }, 'Retry') + ) + ); + case 'done': + return dom('div', + dom('div.embed-form-desc', 'Manage tables published to the cloud via Grist Basket.'), + dom('div.embed-form-desc', 'Note that by default, published tables are public.'), + this._buildTablesDom(), + dom('div.embed-form-desc', 'Basket is used to provide easy access to cloud-synced data:'), + dom('div.embed-form-link', + dom('a', { href: 'https://github.com/gristlabs/basket-api', target: "_blank" }, + 'Basket API on GitHub') + ) + ); + } + }), + kd.maybe(this._updateNotify, update => { + return dom('div.login-success-notify', + dom('div.login-success-text', update) + ); + }), + kd.maybe(this._errorNotify, err => { + return dom('div.login-error-notify', + dom('div.login-error-text', err) + ); + }) + ); +}; + +// Helper to perform embedAction ('add' | 'update' | 'delete') on tableId. +EmbedForm.prototype._embedTable = function(tableId, embedAction) { + this._errorNotify(''); + this._updateNotify(''); + return this._docComm.embedTable(tableId, embedAction) + .then(() => { + return this._refreshTables(); + }) + .then(() => { + if (embedAction === 'update') { + this._updateNotify(`Updated table ${tableId}`); + } + }) + .catch(err => { + this._errorNotify(err.message); + }); +}; + +// Helper to make a button with text, that when pressed performs embedAction +// ('add' | 'update' | 'delete') on tableId. +EmbedForm.prototype._makeButton = function(text, tableId, embedAction) { + return kf.buttonGroup( + kf.button(() => this._embedTable(tableId, embedAction), text) + ); +}; + +// Returns the URL to see the hosted data for tableId. +EmbedForm.prototype._getUrl = function(tableId) { + return `${BASE_URL}/${this._basketId()}/tables/${tableId}`; +}; + +module.exports = EmbedForm; diff --git a/app/client/components/FieldConfigTab.css b/app/client/components/FieldConfigTab.css new file mode 100644 index 00000000..87c5e9a5 --- /dev/null +++ b/app/client/components/FieldConfigTab.css @@ -0,0 +1,9 @@ +.formula_button_f { + font-size: 1.2rem; +} + +.formula_button_x { + font-style: bold; + font-size: 0.9rem; + line-height: 0.9rem; +} diff --git a/app/client/components/FieldConfigTab.js b/app/client/components/FieldConfigTab.js new file mode 100644 index 00000000..e60bb5b6 --- /dev/null +++ b/app/client/components/FieldConfigTab.js @@ -0,0 +1,169 @@ +var ko = require('knockout'); +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var kf = require('../lib/koForm'); +var modelUtil = require('../models/modelUtil'); +var gutil = require('app/common/gutil'); +var AceEditor = require('./AceEditor'); +var RefSelect = require('./RefSelect'); + +const {dom: grainjsDom, makeTestId} = require('grainjs'); +const testId = makeTestId('test-fconfigtab-'); + +function FieldConfigTab(options) { + this.gristDoc = options.gristDoc; + this.fieldBuilder = options.fieldBuilder; + + this.origColRef = this.autoDispose(ko.computed(() => + this.fieldBuilder() ? this.fieldBuilder().origColumn.origColRef() : null)); + + this.isColumnValid = this.autoDispose(ko.computed(() => Boolean(this.origColRef()))); + + this.origColumn = this.autoDispose( + this.gristDoc.docModel.columns.createFloatingRowModel(this.origColRef)); + + this.disableModify = this.autoDispose(ko.computed(() => + this.origColumn.disableModify() || this.origColumn.isTransforming())); + + this.colId = modelUtil.customComputed({ + read: () => this.origColumn.colId(), + save: val => this.origColumn.colId.saveOnly(val) + }); + + this.showColId = this.autoDispose(ko.pureComputed({ + read: () => { + let label = this.origColumn.label(); + let derivedColId = label ? gutil.sanitizeIdent(label) : null; + return derivedColId === this.colId() && !this.origColumn.untieColIdFromLabel(); + } + })); + + this.isDerivedFromLabel = this.autoDispose(ko.pureComputed({ + read: () => !this.origColumn.untieColIdFromLabel(), + write: newValue => this.origColumn.untieColIdFromLabel.saveOnly(!newValue) + })); + + // Indicates whether this is a ref col that references a different table. + this.isForeignRefCol = this.autoDispose(ko.pureComputed(() => { + let type = this.origColumn.type(); + return type && gutil.startsWith(type, 'Ref:') && + this.origColumn.table().tableId() !== gutil.removePrefix(type, 'Ref:'); + })); + + // Create an instance of AceEditor that can be built for each column + this.formulaEditor = this.autoDispose(AceEditor.create({observable: this.origColumn.formula})); + + // Builder for the reference display column multiselect. + this.refSelect = this.autoDispose(RefSelect.create(this)); + + if (options.contentCallback) { + options.contentCallback(this.buildConfigDomObj()); + } else { + this.autoDispose(this.gristDoc.addOptionsTab( + 'Field', dom('span.glyphicon.glyphicon-sort-by-attributes'), + this.buildConfigDomObj(), + { 'category': 'options', 'show': this.fieldBuilder } + )); + } +} +dispose.makeDisposable(FieldConfigTab); + +// Builds object with FieldConfigTab dom builder and settings for the sidepane. +// TODO: Field still cannot be filtered/filter settings cannot be opened from FieldConfigTab. +// This should be considered. +FieldConfigTab.prototype.buildConfigDomObj = function() { + return [{ + 'buildDom': this._buildNameDom.bind(this), + 'keywords': ['field', 'column', 'name', 'title'] + }, { + 'header': true, + 'items': [{ + 'buildDom': this._buildFormulaDom.bind(this), + 'keywords': ['field', 'column', 'formula'] + }] + }, { + 'header': true, + 'label': 'Format Cells', + 'items': [{ + 'buildDom': this._buildFormatDom.bind(this), + 'keywords': ['field', 'type', 'widget', 'options', 'alignment', 'justify', 'justification'] + }] + }, { + 'header': true, + 'label': 'Additional Columns', + 'showObs': this.isForeignRefCol, + 'items': [{ + 'buildDom': () => this.refSelect.buildDom(), + 'keywords': ['additional', 'columns', 'reference', 'formula'] + }] + }, { + 'header': true, + 'label': 'Transform', + 'items': [{ + 'buildDom': this._buildTransformDom.bind(this), + 'keywords': ['field', 'type'] + }] + }]; +}; + +FieldConfigTab.prototype._buildNameDom = function() { + return grainjsDom.maybe(this.isColumnValid, () => dom('div', + kf.row( + 1, dom('div.glyphicon.glyphicon-sort-by-attributes.config_icon'), + 4, kf.label('Field'), + 13, kf.text(this.origColumn.label, { disabled: this.disableModify }, + dom.testId("FieldConfigTab_fieldLabel"), + testId('field-label')) + ), + kf.row( + kd.hide(this.showColId), + 1, dom('div.glyphicon.glyphicon-tag.config_icon'), + 4, kf.label('ID'), + 13, kf.text(this.colId, { disabled: this.disableModify }, + dom.testId("FieldConfigTab_colId"), + testId('field-col-id')) + ), + kf.row( + 8, kf.lightLabel("Use Name as ID?"), + 1, kf.checkbox(this.isDerivedFromLabel, + dom.testId("FieldConfigTab_deriveId"), + testId('field-derive-id')) + ) + )); +}; + +FieldConfigTab.prototype._buildFormulaDom = function() { + return grainjsDom.maybe(this.isColumnValid, () => dom('div', + kf.row( + 3, kf.buttonGroup( + kf.checkButton(this.origColumn.isFormula, + dom('span.formula_button_f', '\u0192'), + dom('span.formula_button_x', 'x'), + kd.toggleClass('disabled', this.disableModify), + { title: 'Change to formula column' } + ) + ), + 15, dom('div.transform_editor', this.formulaEditor.buildDom()) + ), + kf.helpRow( + 3, dom('span'), + 15, kf.lightLabel(kd.text( + () => this.origColumn.isFormula() ? 'Formula' : 'Default Formula')) + ) + )); +}; + +FieldConfigTab.prototype._buildTransformDom = function() { + return grainjsDom.maybe(this.fieldBuilder, builder => builder.buildTransformDom()); +}; + +FieldConfigTab.prototype._buildFormatDom = function() { + return grainjsDom.maybe(this.fieldBuilder, builder => [ + builder.buildSelectTypeDom(), + builder.buildSelectWidgetDom(), + builder.buildConfigDom() + ]); +}; + +module.exports = FieldConfigTab; diff --git a/app/client/components/FormulaTransform.ts b/app/client/components/FormulaTransform.ts new file mode 100644 index 00000000..fe3eb473 --- /dev/null +++ b/app/client/components/FormulaTransform.ts @@ -0,0 +1,54 @@ +/** + * FormulaTransform extends ColumnTransform, creating the transform dom in the field config tab + * used to transform a column of data using a formula. Allows the user to easily and quickly clean + * data or change data to a more useful form. + */ + +// Client libraries +import * as AceEditor from 'app/client/components/AceEditor'; +import {ColumnTransform} from 'app/client/components/ColumnTransform'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {cssButtonRow} from 'app/client/ui/RightPanel'; +import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; +import {testId} from 'app/client/ui2018/cssVars'; +import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; +import {dom} from 'grainjs'; + +/** + * Creates an instance of FormulaTransform for a single field. Extends ColumnTransform. + */ +export class FormulaTransform extends ColumnTransform { + constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) { + super(gristDoc, fieldBuilder); + } + + /** + * Build the transform menu for a formula transform + */ + public buildDom() { + this.editor = this.autoDispose(AceEditor.create({ observable: this.transformColumn.formula })); + return [ + dom('div.transform_menu', + dom('div.transform_editor', + this.buildEditorDom(this.getIdentityFormula()), + testId("formula-transform-top") + ) + ), + cssButtonRow( + basicButton(dom.on('click', () => this.cancel()), + 'Cancel', testId("formula-transform-cancel")), + basicButton(dom.on('click', () => this.editor.writeObservable()), + 'Preview', + dom.cls('disabled', this.formulaUpToDate), + { title: 'Update formula (Shift+Enter)' }, + testId("formula-transform-update")), + primaryButton(dom.on('click', () => this.execute()), + 'Apply', testId("formula-transform-apply")) + ), + ]; + } + + public finalize() { + this.cancel(); + } +} diff --git a/app/client/components/GridView.css b/app/client/components/GridView.css new file mode 100644 index 00000000..538d4adf --- /dev/null +++ b/app/client/components/GridView.css @@ -0,0 +1,237 @@ +.gridview_data_pane { + position: relative; + width: 100%; + overflow: hidden; + flex-grow: 1; + /* make sure that this element is at the back of the stack */ + z-index: 0; + + /* prevent browser selection of cells */ + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + --gridview-header-height: 2.2rem; +} + +.gridview_data_pane.newui { + --gridview-header-height: 24px; +} + +.gridview_data_scroll { + /* Make it position properly */ + position: absolute; + height: 100%; + width: 100%; + overflow: auto; + + z-index: 2; /* scrollbar should be over the overlay background */ + border-top: 1px solid lightgrey; +} + +.gridview_data_pane.newui > .gridview_data_scroll { + border-top: none; +} + +/* ====== Col header stuff */ + +.gridview_stick-top{ + position: -webkit-sticky; + position: sticky; + top: 0px; + z-index: 2; /* z-index must be here, doesnt work on children*/ +} + +.gridview_data_header { + border-bottom: 1px solid lightgray; + position:relative; +} + +.gridview_corner_spacer { /* spacer in .gridview_data_header */ + width: 4rem; /* matches row_num width */ + flex: none; +} + +.field.column_name { + line-height: var(--gridview-header-height); + height: var(--gridview-header-height); /* Also should match height for overlay elements */ +} + +/* also .field.column_name, style set in viewCommon */ + +/* ====== Row stuff */ +/* (more styles in viewCommon.css for .field, .record, etc) */ + +.gridview_row { + display:flex; +} + +.gridview_data_row_num { /* Row nums, stick to the left side */ + position: -webkit-sticky; + position: sticky; + left: 0px; + overflow: hidden; + width: 4rem; /* Also should match width for .gridview_header_corner, and the overlay elements */ + + border-bottom: 1px solid var(--grist-color-dark-grey); + background-color: var(--grist-color-light-grey); + z-index: 2; /* goes over data cells */ + + padding-top: 2px; + text-align: center; + font-size: 1rem; + cursor: pointer; +} + +@media print { + /* For printing, !important tag is needed for background colors to be respected; but normally, + * do not want !important, as it interferes with row selection. + */ + .gridview_data_row_num { + background-color: var(--grist-color-light-grey) !important; + } +} + +/* ========= Overlay styles ========== */ +/* Positioned outside scrollpane, purely visual */ + +.gridview_data_corner_overlay, +.gridview_header_backdrop_top, +.gridview_header_backdrop_left, +.scroll_shadow_top, +.scroll_shadow_left { + position:absolute; + background-color: var(--grist-color-light-grey) !important; +} + +.gridview_data_corner_overlay { + width: 4rem; + height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */ + top: 1px; /* go under 1px border on scrollpane */ + border-bottom: 1px solid lightgray; + z-index: 3; +} + +.scroll_shadow_left { + height: 100%; /* Just needs to be tall enough to flow off the bottom*/ + width: 0px; + left: 4rem; + box-shadow: -6px 0 6px 6px #444; + /* shadow should only show to the right of it (10px should be enough) */ + -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%); + clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%); + z-index: 3; +} + +.scroll_shadow_top { + left: 0; + height: 0; + width: 100%; /* needs to be wide enough to flow off the side*/ + top: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */ + box-shadow: 0 -6px 6px 6px #444; + + /* should only show below it (10px should be enough) */ + -webkit-clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0); + clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0); + z-index: 3; +} + +.gridview_header_backdrop_left { + width: calc(4rem + 1px); /* Matches rowid width (+border) */ + height:100%; + top: 1px; /* go under 1px border on scrollpane */ + z-index: 1; + border-right: 1px solid lightgray; +} + +.gridview_header_backdrop_top { + width: 100%; + height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */ + top: 1px; /* go under 1px border on scrollpane */ + border-bottom: 1px solid lightgray; + z-index: 1; +} + +.gridview_data_pane.newui > .scroll_shadow_top { + top: var(--gridview-header-height); +} + +.gridview_data_pane.newui > .gridview_data_corner_overlay, +.gridview_data_pane.newui > .gridview_header_backdrop_top { + top: 0px; +} + +/* End overlay styles */ + +/* ================ Row/col drag styles*/ + +.col_indicator_line{ + width: 0px; + height: 100%; + position: absolute; + border: 2px solid gray; + z-index: 20; + top: 0px; +} + +.column_shadow{ + width: 0px; + height: 100%; + position: absolute; + border: 1px solid gray; + z-index: 15; + top: 0px; + background-color: #F0F0F0; + opacity: 0.5; +} + +.row_indicator_line{ + width: 100%; + height: 0px; + position: absolute; + border: 2px solid gray; + z-index: 20; + left: 0px; +} + +.row_shadow{ + width: 100%; + height: 0px; + position: absolute; + border: 1px solid gray; + z-index: 15; + left: 0px; + background-color: #F0F0F0; + opacity: 0.5; + pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */ +} + +/* Etc */ + +.g-column-main-menu { + position: absolute; + top: 0; + right: 0; +} + + +.validation_error_number { + position: absolute; + top: -12px; + right: -12px; + width: 24px; + height: 24px; + padding-top: 10px; + padding-right: 10px; + border-radius: 12px; + text-align: center; + font-size: 10px; + font-weight: bold; + background: red; + color: white; +} + +.column_name.mod-add-column { + border-right-width: 1px; + min-width: 40px; + padding-right: 12px; +} diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js new file mode 100644 index 00000000..6eb16e42 --- /dev/null +++ b/app/client/components/GridView.js @@ -0,0 +1,1261 @@ +/* globals alert, document, $ */ + +var _ = require('underscore'); +var ko = require('knockout'); + +var gutil = require('app/common/gutil'); +var BinaryIndexedTree = require('app/common/BinaryIndexedTree'); +var MANUALSORT = require('app/common/gristTypes').MANUALSORT; + +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var kf = require('../lib/koForm'); +var koDomScrolly = require('../lib/koDomScrolly'); +var tableUtil = require('../lib/tableUtil'); +var {addToSort} = require('../lib/sortUtil'); + +var commands = require('./commands'); +var viewCommon = require('./viewCommon'); +var Base = require('./Base'); +var BaseView = require('./BaseView'); +var selector = require('./Selector'); +var CopySelection = require('./CopySelection'); + +const {reportError} = require('app/client/models/AppModel'); + +// Grist UI Components +const {Holder} = require('grainjs'); +const {menu} = require('../ui2018/menus'); +const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu} = require('../ui/GridViewMenus'); +const {setPopupToCreateDom} = require('popweasel'); +const {testId} = require('app/client/ui2018/cssVars'); + +// A threshold for interpreting a motionless click as a click rather than a drag. +// Anything longer than this time (in milliseconds) should be interpreted as a drag +// even if there is no movement. +// This is relevant for distinguishing clicking an already-selected column in order +// to rename it, and starting to drag that column and then deciding to leave it where +// it was. +const SHORT_CLICK_IN_MS = 500; + + +/** + * GridView component implements the view of a grid of cells. + */ +function GridView(gristDoc, viewSectionModel, isPreview = false) { + BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true }); + + this.viewSection = viewSectionModel; + + //-------------------------------------------------- + // Observables local to this view + + // Some observables/variables used for select and drag/drop + this.dragX = ko.observable(0); // x coord of mouse during drag mouse down + this.dragY = ko.observable(0); // ^ for y coord + this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset + this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset + this.scrollLeft = ko.observable(0); + this.scrollTop = ko.observable(0); + this.cellSelector = this.autoDispose(selector.CellSelector.create(this, { + // This is a bit of a hack to prevent dragging when there's an open column menu + isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty()) + })); + this.colMenuTargets = {}; // Reference from column ref to its menu target dom + + // Cache of column right offsets, used to determine the col select range + this.colRightOffsets = this.autoDispose(ko.computed(() => { + let fields = this.viewSection.viewFields(); + let tree = new BinaryIndexedTree(); + tree.fillFromValues(fields.all().map(field => field.widthDef())); + return tree; + })); + + this.autoDispose(this.cursor.fieldIndex.subscribe(idx => { + const offset = this.colRightOffsets.peek().getSumTo(idx); + + const rowNumsWidth = this._cornerDom.clientWidth; + const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; + const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border + + // Left and right pixel edge of 'viewport', starting from edge of row nums + const leftEdge = this.scrollPane.scrollLeft; + const rightEdge = leftEdge + viewWidth; + + //If cell doesnt fit onscreen, scroll to fit + const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); + this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift; + })); + + this.isPreview = isPreview; + + // Some observables for the scroll markers that show that the view is cut off on a side. + this.scrollShadow = { + left: ko.observable(false), + top: ko.observable(false), + }; + + //-------------------------------------------------- + // Set up row and column context menus. + this.ctxMenuHolder = Holder.create(this); + + //-------------------------------------------------- + // Create and attach the DOM for the view. + + this.isColSelected = this.autoDispose(this.viewSection.viewFields().map(function(field) { + return this._createColSelectedObs(field); + }, this)); + this.header = null; + this._cornerDom = null; + this.scrollPane = null; + this.viewPane = this.autoDispose(this.buildDom()); + this.attachSelectorHandlers(); + this.scrolly = koDomScrolly.getInstance(this.viewData); + + //-------------------------------------------------- + // Set up DOM event handling. + + this.onEvent(this.scrollPane, 'dblclick', '.field', function(elem, event) { + // Assumes `click` event also occurs on a `dblclick` and has already repositioned the cursor. + this.activateEditorAtCursor(); + }); + + this.onEvent(this.scrollPane, 'scroll', this.onScroll); + + //-------------------------------------------------- + // Command group implementing all grid level commands. + this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.hasFocus)); + + // Timer to allow short, otherwise non-actionable clicks on column names to trigger renaming. + this._colClickTime = 0; // Units: milliseconds. +} +Base.setBaseFor(GridView); +_.extend(GridView.prototype, BaseView.prototype); + + + +// ====================================================================================== +// GRID-LEVEL COMMANDS + +GridView.gridCommands = { + cursorUp: function() { + // This conditional exists so that when users have the cursor in the top row but are not + // scrolled to the top i.e. in the case of a tall row, pressing up again will scroll the + // pane to the top. + if (this.cursor.rowIndex() === 0) { + this.scrollPane.scrollTop = 0; + } + this.cursor.rowIndex(this.cursor.rowIndex() - 1); + }, + shiftDown: function() { + this._shiftSelect(1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex()); + }, + shiftUp: function() { + this._shiftSelect(-1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex()); + }, + shiftRight: function() { + this._shiftSelect(1, this.cellSelector.col.end, selector.ROW, + this.viewSection.viewFields().peekLength - 1); + }, + shiftLeft: function() { + this._shiftSelect(-1, this.cellSelector.col.end, selector.ROW, + this.viewSection.viewFields().peekLength - 1); + }, + fillSelectionDown: function() { this.fillSelectionDown(); }, + selectAll: function() { this.selectAll(); }, + + fieldEditSave: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); }, + // Re-define editField after fieldEditSave to make it take precedence for the Enter key. + editField: function() { this.activateEditorAtCursor(); }, + + deleteRecords: function() { + const saved = this.cursor.getCursorPos(); + this.cursor.setLive(false); + + // Don't return a promise. Nothing will use it, and the Command implementation will not + // prevent default browser behavior if we return a truthy value. + this.deleteRows(this.getSelection()) + .finally(() => { + this.cursor.setCursorPos(saved); + this.cursor.setLive(true); + }) + .catch(reportError); + }, + insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); }, + insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); }, + renameField: function() { this.currentEditingColumnIndex(this.cursor.fieldIndex()); }, + hideField: function() { this.hideField(this.cursor.fieldIndex()); }, + deleteFields: function() { this.deleteColumns(this.getSelection()); }, + clearValues: function() { this.clearValues(this.getSelection()); }, + copy: function() { return this.copy(this.getSelection()); }, + cut: function() { return this.cut(this.getSelection()); }, + paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); }, + cancel: function() { this.clearSelection(); }, + sortAsc: function() { + this.viewSection.activeSortSpec.assign([this.currentColumn().getRowId()]); + }, + sortDesc: function() { + this.viewSection.activeSortSpec.assign([-this.currentColumn().getRowId()]); + }, + addSortAsc: function() { + addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId()); + }, + addSortDesc: function() { + addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId()); + } +}; + +GridView.prototype.onTableLoaded = function() { + BaseView.prototype.onTableLoaded.call(this); + this.onScroll(); + + // Initialize scroll position. + this.scrollPane.scrollLeft = this.viewSection.lastScrollPos.scrollLeft; + this.scrolly.scrollToSavedPos(this.viewSection.lastScrollPos); +}; + +/** + * Update the bounds of the cell selector's selected range for Shift+Direction keyboard shortcuts. + * @param {integer} step - amount to increase/decrease the select bound + * @param {Observable} selectObs - observable to change + * @exemptType {Selector type string} - selector type to noop on + IE: Shift + Up/Down should noop if columns are selected. And vice versa for rows. + * @param {integer} maxVal - maximum value allowed for the selectObs + **/ +GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) { + console.assert(exemptType === selector.ROW || exemptType === selector.COL); + if (this.cellSelector.isCurrentSelectType(exemptType)) return; + if (this.cellSelector.isCurrentSelectType(selector.NONE)) { + this.cellSelector.currentSelectType(selector.CELL); + } + var newVal = gutil.clamp(selectObs() + step, 0, maxVal); + selectObs(newVal); +}; + +/** + * Pastes the provided data at the current cursor. + * + * TODO: Handle the edge case where more columns are pasted than available. + * + * @param {Array} data - Array of arrays of data to be pasted. Each array represents a row. + * i.e. [["1-1", "1-2", "1-3"], + * ["2-1", "2-2", "2-3"]] + * @param {Function} cutCallback - If provided returns the record removal action needed for + * a cut. + */ +GridView.prototype.paste = function(data, cutCallback) { + // TODO: If pasting into columns by which this view is sorted, rows may jump. It is still better + // to allow it, but we should "freeze" the affected rows to prevent them from jumping, until the + // user re-applies the sort manually. (This is a particularly bad experience when rows get + // dispersed by the sorting after paste.) We do attempt to keep the cursor in the same row as + // before even if it jumped. Note when addressing it: currently selected rows should be treated + // as frozen (and get marked as unsorted if necessary) for any update even if the update comes + // from a different peer. + + // convert row-wise data to column-wise so that it better resembles a useraction + let pasteData = _.unzip(data); + let pasteHeight = pasteData[0].length; + let pasteWidth = pasteData.length; + // figure out the size of the paste area + let outputHeight = Math.max(gutil.roundDownToMultiple(this.cellSelector.rowCount(), pasteHeight), pasteHeight); + let outputWidth = Math.max(gutil.roundDownToMultiple(this.cellSelector.colCount(), pasteWidth), pasteWidth); + // get the row ids that cover the paste + let topIndex = this.cellSelector.rowLower(); + let updateRowIndices = _.range(topIndex, topIndex + outputHeight); + let updateRowIds = updateRowIndices.map(r => this.viewData.getRowId(r)); + // get the col ids that cover the paste + let leftIndex = this.cellSelector.colLower(); + let updateColIndices = _.range(leftIndex, leftIndex + outputWidth); + + pasteData = gutil.growMatrix(pasteData, updateColIndices.length, updateRowIds.length); + + let fields = this.viewSection.viewFields().peek(); + let pasteCols = updateColIndices.map(i => fields[i] && fields[i].column() || null); + + let richData = this._parsePasteForView(pasteData, pasteCols); + let actions = this._createBulkActionsFromPaste(updateRowIds, richData); + + if (actions.length > 0) { + let cursorPos = this.cursor.getCursorPos(); + return this.sendPasteActions(cutCallback, actions) + .then(results => { + // If rows were added, get their rowIds from the action results. + let addRowIds = (actions[0][0] === 'BulkAddRecord' ? results[0] : []); + console.assert(addRowIds.length <= updateRowIds.length, + `Unexpected number of added rows: ${addRowIds.length} of ${updateRowIds.length}`); + let newRowIds = updateRowIds.slice(0, updateRowIds.length - addRowIds.length) + .concat(addRowIds); + + // Restore the cursor to the right rowId, even if it jumped. + this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowIds[0] : cursorPos.rowId}); + + // Restore the selection if it would select the correct rows. + let topRowIndex = this.viewData.getRowIndex(newRowIds[0]); + if (newRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) { + this.cellSelector.selectArea(topRowIndex, leftIndex, + topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1); + } + + this.copySelection(null); + }); + } +}; + +/** + * Given a matrix of values, and an array of colIds and rowId targets, this function returns + * an array of user actions needed to update the targets to the values in the matrix + * @param {Array} rowIds - An array of numbers, 'new' or null corresponding to the row ids will + * be updated or added. Numerical (proper) rowIds must come before special ones. + * @param {Object} bulkUpdate - Object from colId to array of column values. + */ +GridView.prototype._createBulkActionsFromPaste = function(rowIds, bulkUpdate) { + if (_.isEmpty(bulkUpdate)) { + return []; + } + + let addRows = rowIds.filter(rowId => rowId === null || rowId === 'new').length; + let updateRows = rowIds.length - addRows; + + let actions = []; + if (addRows > 0) { + actions.push(['BulkAddRecord', gutil.arrayRepeat(addRows, null), + _.mapObject(bulkUpdate, values => values.slice(-addRows)) + ]); + } + if (updateRows > 0) { + actions.push(['BulkUpdateRecord', rowIds.slice(0, updateRows), + _.mapObject(bulkUpdate, values => values.slice(0, updateRows)) + ]); + } + return this.prepTableActions(actions); +}; + +/** + * Fills currently selected grid with the contents of the top row in that selection. + */ +GridView.prototype.fillSelectionDown = function() { + var rowLower = this.cellSelector.rowLower(); + var rowIds = _.times(this.cellSelector.rowCount(), i => this.viewData.getRowId(rowLower + i)); + + if (rowIds.length <= 1) { + return; + } + + var colLower = this.cellSelector.colLower(); + var fields = this.viewSection.viewFields().peek(); + var colIds = _.times(this.cellSelector.colCount(), i => { + if (!fields[colLower + i].column().isFormula()) { + return fields[colLower + i].colId(); + } + }).filter(colId => colId); + + var colInfo = _.object(colIds, colIds.map(colId => { + var val = this.tableModel.tableData.getValue(rowIds[0],colId); + return rowIds.map(() => val); + })); + + this.tableModel.sendTableAction(["BulkUpdateRecord",rowIds,colInfo]); +}; + + + + +/** + * Returns a GridSelection of the selected rows and cols + * @returns {Object} CopySelection + */ +GridView.prototype.getSelection = function() { + var rowIds = [], fields = [], rowStyle = {}, colStyle = {}; + var colStart = this.cellSelector.colLower(); + var colEnd = this.cellSelector.colUpper(); + var rowStart = this.cellSelector.rowLower(); + var rowEnd = this.cellSelector.rowUpper(); + + // If there is no selection, just copy/paste the cursor cell + if (this.cellSelector.isCurrentSelectType(selector.NONE)) { + rowStart = rowEnd = this.cursor.rowIndex(); + colStart = colEnd = this.cursor.fieldIndex(); + } + + // Get all the cols if rows are selected, and viceversa + if (this.cellSelector.isCurrentSelectType(selector.ROW)) { + colStart = 0; + colEnd = this.viewSection.viewFields().peekLength - 1; + } else if(this.cellSelector.isCurrentSelectType(selector.COL)) { + rowStart = 0; + rowEnd = this.getLastDataRowIndex(); + } + + var rowId; + for(var i = colStart; i <= colEnd; i++) { + let field = this.viewSection.viewFields().at(i); + fields.push(field); + colStyle[field.colId()] = this._getColStyle(i); + } + for(var j = rowStart; j <= rowEnd; j++) { + rowId = this.viewData.getRowId(j); + rowIds.push(rowId); + rowStyle[rowId] = this._getRowStyle(j); + } + return new CopySelection(this.tableModel.tableData, rowIds, fields, { + rowStyle: rowStyle, + colStyle: colStyle + }); +}; + +/** + * Deselects the currently selected cells. + */ +GridView.prototype.clearSelection = function() { + this.copySelection(null); // Unset the selection observable + this.cellSelector.setToCursor(); +}; + +/** + * Given a selection object, sets all references in the object to the empty string. + * @param {Object} selection + */ +GridView.prototype.clearValues = function(selection) { + console.debug('GridView.clearValues', selection); + selection.rowIds = _.without(selection.rowIds, 'new'); + if (selection.rowIds.length === 0) { + // If only the addRow was selected, don't send an action. + return; + } else if (selection.fields.length === 1 && selection.fields[0].column().isRealFormula()) { + this.activateEditorAtCursor(''); + } else { + let clearAction = tableUtil.makeDeleteAction(selection); + if (clearAction) { + this.gristDoc.docData.sendAction(clearAction); + } + } +}; + +GridView.prototype.selectAll = function() { + this.cellSelector.selectArea(0, 0, this.getLastDataRowIndex(), + this.viewSection.viewFields().peekLength - 1); +}; + + +// End of actions + + + +// ====================================================================================== +// GRIDVIEW PRIMITIVES (for manipulating grid, rows/cols, selections) + + +/** + * Assigns the cursor.rowIndex and cursor.fieldIndex observable to the correct row/column/cell + * depending on the supplied dom element. + * @param {DOM element} elem - extract the col/row index from the element + * @param {Selector.ROW/COL/CELL} elemType - denotes whether the clicked element was + * a row header, col header or cell + */ +GridView.prototype.assignCursor = function(elem, elemType) { + // Change focus before running command so that the correct viewsection's cursor is moved. + this.viewSection.hasFocus(true); + + try { + let row = this.domToRowModel(elem, elemType); + let col = this.domToColModel(elem, elemType); + commands.allCommands.setCursor.run(row, col); + } catch(e) { + console.error(e); + console.error("GridView.assignCursor expects a row/col header, or cell as an input."); + } + + + this.cellSelector.currentSelectType(elemType); +}; + +GridView.prototype.deleteRows = function(selection) { + if (!this.viewSection.disableAddRemoveRows()) { + var rowIds = _.without(selection.rowIds, 'new'); + if (rowIds.length > 0) { + return this.tableModel.sendTableAction(['BulkRemoveRecord', rowIds]); + } + } + return Promise.resolve(); +}; + +GridView.prototype.addNewColumn = function() { + this.insertColumn(this.viewSection.viewFields().peekLength) + .then(() => this.scrollPaneRight()); +}; + +GridView.prototype.insertColumn = function(index) { + var pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0]; + var action = ['AddColumn', null, {"_position": pos}]; + return this.tableModel.sendTableAction(action) + .bind(this).then(function() { + this.selectColumn(index); + this.currentEditingColumnIndex(index); + // this.columnConfigTab.show(); + }); +}; + +GridView.prototype.scrollPaneRight = function() { + this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER; +}; + +GridView.prototype.selectColumn = function(colIndex) { + this.cursor.fieldIndex(colIndex); + this.cellSelector.currentSelectType(selector.COL); +}; + +GridView.prototype.showColumn = function(colId, index) { + let fieldPos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index, 1)[0]; + let colInfo = { + parentId: this.viewSection.id(), + colRef: colId, + parentPos: fieldPos + }; + return this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]) + .then(() => this.selectColumn(index)) + .then(() => this.scrollPaneRight()); +}; + +// TODO: Replace alerts with custom notifications +GridView.prototype.deleteColumns = function(selection) { + var fields = selection.fields; + if (fields.length === this.viewSection.viewFields().peekLength) { + alert("You can't delete all the columns on the grid."); + return; + } + let actions = fields.filter(col => !col.disableModify()).map(col => ['RemoveColumn', col.colId()]); + if (actions.length > 0) { + this.tableModel.sendTableActions(actions, `Removed columns ${actions.map(a => a[1]).join(', ')} ` + + `from ${this.tableModel.tableData.tableId}.`); + } +}; + +GridView.prototype.hideField = function(index) { + var field = this.viewSection.viewFields().at(index); + var action = ['RemoveRecord', field.id()]; + return this.gristDoc.docModel.viewFields.sendTableAction(action); +}; + +GridView.prototype.moveColumns = function(oldIndices, newIndex) { + if (oldIndices.length === 0) return; + if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) return; + + var newPositions = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), newIndex, + oldIndices.length); + var vsfRowIds = oldIndices.map(function(i) { + return this.viewSection.viewFields().at(i).id(); + }, this); + var colInfo = { 'parentPos': newPositions }; + var vsfAction = ['BulkUpdateRecord', vsfRowIds, colInfo]; + var viewFieldsTable = this.gristDoc.docModel.viewFields; + var numCols = oldIndices.length; + var self = this; + viewFieldsTable.sendTableAction(vsfAction).then(function() { + self._selectMovedElements(self.cellSelector.col.start, self.cellSelector.col.end, + newIndex, numCols, selector.COL); + }); +}; + +GridView.prototype.moveRows = function(oldIndices, newIndex) { + if (oldIndices.length === 0) return; + if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) return; + + var newPositions = this._getRowInsertPos(newIndex, oldIndices.length); + var rowIds = oldIndices.map(function(i) { + return this.viewData.getRowId(i); + }, this); + var colInfo = { 'manualSort': newPositions }; + var action = ['BulkUpdateRecord', rowIds, colInfo]; + var numRows = oldIndices.length; + var self = this; + this.tableModel.sendTableAction(action).then(function() { + self._selectMovedElements(self.cellSelector.row.start, self.cellSelector.row.end, + newIndex, numRows, selector.ROW); + }); +}; + +/** + * Return a list of manual sort positions so that inserting {numInsert} rows + * with the returned positions will place them in between index-1 and index. + * when the GridView is sorted by MANUALSORT + **/ +GridView.prototype._getRowInsertPos = function(index, numInserts) { + var lowerRowId = this.viewData.getRowId(index-1); + var upperRowId = this.viewData.getRowId(index); + if (lowerRowId === 'new') { + // set the lowerRowId to the rowId of the row before 'new'. + lowerRowId = this.viewData.getRowId(index - 2); + } + + var lowerPos = this.tableModel.tableData.getValue(lowerRowId, MANUALSORT); + var upperPos = this.tableModel.tableData.getValue(upperRowId, MANUALSORT); + // tableUtil.insertPositions takes care of cases where upper/lowerPos are non-zero & falsy + return tableUtil.insertPositions(lowerPos, upperPos, numInserts); +}; + + +// ====================================================================================== +// MISC HELPERS + + +/** + * Returns the row index of the row whose top offset is closest to and + * no greater than given y-position. + * param{yCoord}: The mouse y-position (including any scroll top amount). + * Assumes that scrolly.rowOffsetTree is up to date. + * See the given examples in GridView.getMousePosCol. + **/ +GridView.prototype.getMousePosRow = function (yCoord) { + var headerOffset = this.header.getBoundingClientRect().bottom; + return this.scrolly.rowOffsetTree.getIndex(yCoord - headerOffset); +}; + +/** + * Returns the row index of the row whose top offset is closest to and + * no greater than given y-position excluding addRows. + * param{yCoord}: The mouse y-position on the screen. + **/ +GridView.prototype.currentMouseRow = function(yCoord) { + return Math.min(this.getMousePosRow(this.scrollTop() + yCoord), this.getLastDataRowIndex()); +}; + +/** + * Returns the column index of the column whose left position is closest to and + * no greater than given x-position. + * param{xCoord}: The mouse x-position (including any scroll left amount). + * Assumes that this.colRightOffsets is up to date + * In the following examples, let * denote the current mouse position. + * * |0____|1____|2____|3____| Returns 0 + * |0__*_|1____|2____|3____| Returns 0 + * |0____|1__*_|2____|3____| Returns 1 + * |0____|1____|2__*_|3____| Returns 2 + * |0____|1____|2____|3__*_| Returns 3 + * |0____|1____|2____|3____| * Returns 4 + **/ +GridView.prototype.getMousePosCol = function (xCoord) { + //offset to left edge of gridView viewports + var headerOffset = this._cornerDom.getBoundingClientRect().right; + return this.colRightOffsets.peek().getIndex(xCoord - headerOffset); +}; + +// Used for styling the paste data the same way the col/row is styled in the GridView. +GridView.prototype._getRowStyle = function(rowIndex) { + return { 'height': this.scrolly.rowOffsetTree.getValue(rowIndex) + 'px' }; +}; + +GridView.prototype._getColStyle = function(colIndex) { + return { 'width' : this.viewSection.viewFields().at(colIndex).widthPx() }; +}; + + +// TODO: for now lets just assume youre clicking on a .field, .row, or .column +GridView.prototype.domToRowModel = function(elem, elemType) { + switch (elemType) { + case selector.COL: + return 0; + case selector.ROW: // row > row num: row has record model + return ko.utils.domData.get(elem.parentNode, 'itemModel'); + case selector.NONE: + case selector.CELL: // cell: row > .record > .field, row holds row model + return ko.utils.domData.get(elem.parentNode.parentNode, 'itemModel'); + default: + throw Error("Unknown elemType in domToRowModel:" + elemType); + } +}; + +GridView.prototype.domToColModel = function(elem, elemType) { + switch (elemType) { + case selector.ROW: + return 0; + case selector.NONE: + case selector.CELL: // cell: .field has col model + case selector.COL: // col: .column_name I think + return ko.utils.domData.get(elem, 'itemModel'); + default: + throw Error("Unknown elemType in domToRowModel"); + } +}; + +// ====================================================================================== +// DOM STUFF + +/** + * Recalculate various positioning variables. + */ +//TODO : is this necessary? make passive. Also this could be removed soon I think +GridView.prototype.onScroll = function() { + var pane = this.scrollPane; + this.scrollShadow.left(pane.scrollLeft > 0); + this.scrollShadow.top(pane.scrollTop > 0); + this.scrollLeft(pane.scrollLeft); + this.scrollTop(pane.scrollTop); +}; + + +GridView.prototype.buildDom = function() { + var self = this; + var data = this.viewData; + var v = this.viewSection; + var editIndex = this.currentEditingColumnIndex; + + //each row has toggle classes on these props, so grab them once to save on lookups + let vHorizontalGridlines = v.optionsObj.prop('horizontalGridlines'); + let vVerticalGridlines = v.optionsObj.prop('verticalGridlines'); + let vZebraStripes = v.optionsObj.prop('zebraStripes'); + + const rightAddRows = new Set(this.rightTableDelta && this.rightTableDelta.addRows.map(id => -id)); + const rightRemoveRows = new Set(this.rightTableDelta && this.rightTableDelta.removeRows); + const leftAddRows = new Set(this.leftTableDelta && this.leftTableDelta.addRows); + + var renameCommands = { + nextField: function() { + editIndex(editIndex() + 1); + self.selectColumn(editIndex.peek()); + }, + prevField: function() { + editIndex(editIndex() - 1); + self.selectColumn(editIndex.peek()); + } + }; + + return dom( + 'div.gridview_data_pane.flexvbox', + this.gristDoc.app.addNewUIClass(), + + // Corner, bars and shadows + // Corner and shadows (so it's fixed to the grid viewport) + self._cornerDom = dom('div.gridview_data_corner_overlay'), + dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)), + dom('div.scroll_shadow_left', kd.show(this.scrollShadow.left)), + dom('div.gridview_header_backdrop_left'), //these hide behind the actual headers to keep them from flashing + dom('div.gridview_header_backdrop_top'), + + // Drag indicators + self.colLine = dom( + 'div.col_indicator_line', + kd.show(function() { return self.cellSelector.isCurrentDragType(selector.COL); }), + kd.style('left', self.cellSelector.col.linePos) + ), + self.colShadow = dom( + 'div.column_shadow', + kd.show(function() { return self.cellSelector.isCurrentDragType(selector.COL); }), + kd.style('left', function() { return (self.dragX() - self.colShadowAdjust) + 'px'; }) + ), + self.rowLine = dom( + 'div.row_indicator_line', + kd.show(function() { return self.cellSelector.isCurrentDragType(selector.ROW); }), + kd.style('top', self.cellSelector.row.linePos) + ), + self.rowShadow = dom( + 'div.row_shadow', + kd.show(function() { return self.cellSelector.isCurrentDragType(selector.ROW); }), + kd.style('top', function() { return (self.dragY() - self.rowShadowAdjust) + 'px'; }) + ), + + self.scrollPane = + dom('div.grid_view_data.gridview_data_scroll.show_scrollbar', + kd.scrollChildIntoView(self.cursor.rowIndex), + dom.onDispose(() => { + // Save the previous scroll values to the section. + self.viewSection.lastScrollPos = _.extend({ + scrollLeft: self.scrollPane.scrollLeft + }, self.scrolly.getScrollPos()); + }), + + // COL HEADER BOX + dom('div.gridview_stick-top.flexhbox', // Sticks to top, flexbox makes child enclose its contents + dom('div.gridview_corner_spacer'), + + self.header = dom('div.gridview_data_header.flexhbox', // main header, flexbox floats contents onto a line + + dom('div.column_names.record', + kd.style('minWidth', '100%'), + kd.style('borderLeftWidth', v.borderWidthPx), + kd.foreach(v.viewFields(), field => { + var isEditingLabel = ko.pureComputed({ + read: () => this.gristDoc.isReadonlyKo() ? false : editIndex() === field._index(), + write: val => editIndex(val ? field._index() : -1) + }).extend({ rateLimit: 0 }); + let filterTriggerCtl; + return dom( + 'div.column_name.field', + dom.autoDispose(isEditingLabel), + dom.testId("GridView_columnLabel"), + kd.style('width', field.widthPx), + kd.style('borderRightWidth', v.borderWidthPx), + viewCommon.makeResizable(field.width), + kd.toggleClass('selected', () => ko.unwrap(this.isColSelected.at(field._index()))), + dom.on('contextmenu', ev => { + // This is a little hack to position the menu the same way as with a click + ev.preventDefault(); + ev.currentTarget.querySelector('.g-column-menu-btn').click(); + }), + dom('div.g-column-label', + kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands), + dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) + ), + this.isPreview ? null : dom('div.g-column-main-menu.g-column-menu-btn.right-btn', + dom('span.glyphicon.glyphicon-triangle-bottom'), + // Prevent mousedown on the dropdown triangle from initiating column drag. + dom.on('mousedown', () => false), + // Select the column if it's not part of a multiselect. + dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)), + (elem) => { + filterTriggerCtl = setPopupToCreateDom(elem, ctl => this._columnFilterMenu(ctl, field), { + attach: 'body', + placement: 'bottom-start', + boundaries: 'viewport', + trigger: [], + }); + }, + menu(ctl => this.columnContextMenu(ctl, this.getSelection().colIds, field, filterTriggerCtl)), + testId('column-menu-trigger'), + ) + ); + }), + this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( + dom('div.column_name.mod-add-column.field', + '+', + dom.on('click', ev => { + // If there are no hidden columns, clicking the plus just adds a new column. + // If there are hidden columns, display a dropdown menu. + if (this.viewSection.hiddenColumns().length === 0) { + ev.stopImmediatePropagation(); // Don't open the menu defined below + this.addNewColumn(); + } + }), + menu((ctl => ColumnAddMenu(this, this.viewSection))) + ) + )) + ) + ) //end hbox + ), // END COL HEADER BOX + + koDomScrolly.scrolly(data, { paddingBottom: 80, paddingRight: 28 }, function(row) { + // TODO. There are several ways to implement a cursor; similar concerns may arise + // when implementing selection and cell editor. + // (1) Class on 'div.field.field_clip'. Fewest elements, seems possibly best for + // performance. Problem is: it's impossible to get cursor exactly right with a + // one-sided border. Attaching a cursor as additional element inside the cell + // truncates the cursor to the cell's inside because of 'overflow: hidden'. + // (2) 'div.field' with 'div.field_clip' inside, on which a class is toggled. This + // works well. The only concern is whether this slows down rendering. Would be + // good to measure and compare rendering speed. + // Related: perhaps the fastest rendering would be for a table. + // (3) Separate element attached to the row, absolutely positioned at left + // position and width of the selected cell. This works too. Requires + // maintaining a list of leftOffsets (or measuring the cell's), and feels less + // clean and more complicated than (2). + + // IsRowActive and isCellActive are a significant optimization. IsRowActive is called + // for all rows when cursor.rowIndex changes, but the value only changes for two of the + // rows. IsCellActive is only subscribed to columns for the active row. This way, when + // the cursor moves, there are (rows+2*columns) calls rather than rows*columns. + var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex()); + return dom('div.gridview_row', + dom.autoDispose(isRowActive), + + // rowid dom + dom('div.gridview_data_row_num', + dom('div.gridview_data_row_info', + kd.toggleClass('linked_dst', () => { + // Must ensure that linkedRowId is not null to avoid drawing on rows whose + // row ids are null. + return self.linkedRowId() && self.linkedRowId() === row.getRowId(); + }) + ), + kd.text(function() { return row._index() + 1; }), + + kd.scope(row._validationFailures, function(failures) { + if (!row._isAddRow() && failures.length > 0) { + return dom('div.validation_error_number', failures.length, + kd.attr('title', function() { + return "Validation failed: " + + failures.map(function(val) { return val.name(); }).join(", "); + }) + ); + } + }), + kd.toggleClass('selected', () => + !row._isAddRow() && self.cellSelector.isRowSelected(row._index())), + dom.on('contextmenu', ev => self.maybeSelectRow(ev.currentTarget, row.getRowId())), + menu(ctl => RowContextMenu({ + disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()), + disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()), + isViewSorted: self.viewSection.activeSortSpec.peek().length > 0, + }), { trigger: ['contextmenu'] }), + ), + + + dom('div.record', + kd.toggleClass('record-add', row._isAddRow), + kd.style('borderLeftWidth', v.borderWidthPx), + kd.style('borderBottomWidth', v.borderWidthPx), + //These are grabbed from v.optionsObj at start of GridView buildDom + kd.toggleClass('record-hlines', vHorizontalGridlines), + kd.toggleClass('record-vlines', vVerticalGridlines), + kd.toggleClass('record-zebra', vZebraStripes), + // even by 1-indexed rownum, so +1 (makes more sense for user-facing display stuff) + kd.toggleClass('record-even', () => (row._index()+1) % 2 === 0 ), + + self.comparison ? kd.cssClass(() => { + var rowId = row.id(); + if (rightAddRows.has(rowId)) { return 'diff-remote'; } + else if (rightRemoveRows.has(rowId)) { return 'diff-parent'; } + else if (leftAddRows.has(rowId)) { return 'diff-local'; } + return ''; + }) : null, + + kd.foreach(v.viewFields(), function(field) { + // Whether the cell has a cursor (possibly in an inactive view section). + var isCellSelected = ko.computed(() => + isRowActive() && field._index() === self.cursor.fieldIndex()); + + // Whether the cell is active: has the cursor in the active section. + var isCellActive = ko.computed(() => isCellSelected() && v.hasFocus()); + + // Whether the cell is part of an active copy-paste operation. + var isCopyActive = ko.computed(function() { + return self.copySelection() && + self.copySelection().isCellSelected(row.id(), field.colId()); + }); + var fieldBuilder = self.fieldBuilders.at(field._index()); + var isSelected = ko.computed(() => { + return !row._isAddRow() && + !self.cellSelector.isCurrentSelectType(selector.NONE) && + ko.unwrap(self.isColSelected.at(field._index())) && + self.cellSelector.isRowSelected(row._index()); + }); + return dom( + 'div.field', + kd.toggleClass('scissors', isCopyActive), + dom.autoDispose(isCopyActive), + dom.autoDispose(isCellSelected), + dom.autoDispose(isCellActive), + dom.autoDispose(isSelected), + kd.style('width', field.widthPx), + //TODO: Ensure that fields in a row resize when + //a cell in that row becomes larger + kd.style('borderRightWidth', v.borderWidthPx), + kd.style('color', field.textColor), + // If making a comparison, use the background exclusively for + // marking that up. + self.comparison ? null : kd.style('background-color', () => (row._isAddRow() || isSelected()) ? '' : field.fillColor()), + + kd.toggleClass('selected', isSelected), + fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected) + ); + }) + ) + ); + }) //end scrolly + + ) // end scrollpane + );// END MAIN VIEW BOX +}; + +/** @inheritdoc */ +GridView.prototype.onResize = function() { + this.scrolly.scheduleUpdateSize(); +}; + +/** @inheritdoc */ +GridView.prototype.onRowResize = function(rowModels) { + this.scrolly.resetItemHeights(rowModels); +}; + +// ====================================================================================== +// SELECTOR STUFF + +/** + * Returns a pure computed boolean that determines whether the given column is selected. + * @param {view field object} col - the column to create an observable for + **/ +GridView.prototype._createColSelectedObs = function(col) { + return ko.pureComputed(function() { + return this.cellSelector.isCurrentSelectType(selector.ROW) || + gutil.between(col._index(), this.cellSelector.col.start(), + this.cellSelector.col.end()); + }, this); +}; + +// Callbacks for mouse events for the selector object + +GridView.prototype.cellMouseDown = function(elem, event) { + if (event.shiftKey) { + // Change focus before running command so that the correct viewsection's cursor is moved. + this.viewSection.hasFocus(true); + let row = this.domToRowModel(elem, selector.CELL); + let col = this.domToColModel(elem, selector.CELL); + this.cellSelector.selectArea(this.cursor.rowIndex(), this.cursor.fieldIndex(), + row._index(), col._index()); + } else { + this.assignCursor(elem, selector.NONE); + } +}; + +GridView.prototype.colMouseDown = function(elem, event) { + this._colClickTime = Date.now(); + this.assignCursor(elem, selector.COL); + // Clicking the column header selects all rows except the add row. + this.cellSelector.row.end(this.getLastDataRowIndex()); +}; + +GridView.prototype.rowMouseDown = function(elem, event) { + if (event.shiftKey) { + this.cellSelector.currentSelectType(selector.ROW); + this.cellSelector.row.end(this.currentMouseRow(event.pageY)); + } else { + this.assignCursor(elem, selector.ROW); + } +}; + +GridView.prototype.rowMouseMove = function(elem, event) { + this.cellSelector.row.end(this.currentMouseRow(event.pageY)); +}; + +GridView.prototype.colMouseMove = function(elem, event) { + var currentCol = Math.min(this.getMousePosCol(this.scrollLeft() + event.pageX), + this.viewSection.viewFields().peekLength - 1); + this.cellSelector.col.end(currentCol); +}; + +GridView.prototype.cellMouseMove = function(elem, event, extra) { + this.colMouseMove(elem, event); + this.rowMouseMove(elem, event); + // Maintain single cells cannot be selected invariant + if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) { + this.cellSelector.currentSelectType(selector.NONE); + } else { + this.cellSelector.currentSelectType(selector.CELL); + } +}; + +GridView.prototype.createSelector = function() { + this.cellSelector = new selector.CellSelector(this); +}; + +// buildDom needs some of the row/col/cell selector observables to exist beforehand +// but we can't attach any of the mouse handlers in the Selector class until the +// dom elements exist so we attach the selector handlers separately from instantiation +GridView.prototype.attachSelectorHandlers = function () { + // We attach mousemove and mouseup to document so that selecting and drag/dropping + // work even if the mouse leaves the view pane: http://news.qooxdoo.org/mouse-capturing + // Mousemove/up events fire to document even if the mouse leaves the browser window. + var rowCallbacks = { + 'disableDrag': this.viewSection.disableDragRows, + 'mousedown': { 'select': this.rowMouseDown, + 'drag': this.styleRowDragElements, + 'elemName': '.gridview_data_row_num', + 'source': this.viewPane, + }, + 'mousemove': { 'select': this.rowMouseMove, + 'drag': this.dragRows, + 'source': document, + }, + 'mouseup': { 'select': this.rowMouseUp, + 'drag': this.dropRows, + 'source': document, + } + }; + var colCallbacks = { + 'mousedown': { 'select': this.colMouseDown, + 'drag': this.styleColDragElements, + // Trigger on column headings but not on the add column button + 'elemName': '.column_name.field:not(.mod-add-column)', + 'source': this.viewPane, + }, + 'mousemove': { 'select': this.colMouseMove, + 'drag': this.dragCols, + 'source': document, + }, + 'mouseup': { 'drag': this.dropCols, + 'source': document, + } + }; + var cellCallbacks = { + 'mousedown': { 'select': this.cellMouseDown, + 'drag' : function(elem) { this.assignCursor(elem, selector.NONE); }, + 'elemName': '.field:not(.column_name)', + 'source': this.scrollPane + }, + 'mousemove': { 'select': this.cellMouseMove, + 'source': document, + }, + 'mouseup': { 'select': this.cellMouseUp, + 'source': document, + } + }; + + this.cellSelector.registerMouseHandlers(rowCallbacks, selector.ROW); + this.cellSelector.registerMouseHandlers(colCallbacks, selector.COL); + this.cellSelector.registerMouseHandlers(cellCallbacks, selector.CELL); +}; + +// End of Selector stuff + +// ============================================================================ +// DRAGGING LOGIC + +GridView.prototype.styleRowDragElements = function(elem, event) { + var rowStart = this.cellSelector.rowLower(); + var rowEnd = this.cellSelector.rowUpper(); + var shadowHeight = this.scrolly.rowOffsetTree.getCumulativeValueRange(rowStart, rowEnd+1); + var shadowTop = (this.header.getBoundingClientRect().height + + this.scrolly.rowOffsetTree.getSumTo(rowStart) - this.scrollTop()); + + this.rowLine.style.top = shadowTop + 'px'; + this.rowShadow.style.top = shadowTop + 'px'; + this.rowShadow.style.height = shadowHeight + 'px'; + this.rowShadowAdjust = event.pageY - shadowTop; + this.cellSelector.currentDragType(selector.ROW); + this.cellSelector.row.dropIndex(this.cellSelector.rowLower()); +}; + +GridView.prototype.styleColDragElements = function(elem, event) { + this._colClickTime = Date.now(); + var colStart = this.cellSelector.colLower(); + var colEnd = this.cellSelector.colUpper(); + var shadowWidth = this.colRightOffsets.peek().getCumulativeValueRange(colStart, colEnd+1); + var viewDataNumsWidth = $('.gridview_corner_spacer').width(); + var shadowLeft = (viewDataNumsWidth + this.colRightOffsets.peek().getSumTo(colStart) - this.scrollLeft()); + + this.colLine.style.left = shadowLeft + 'px'; + this.colShadow.style.left = shadowLeft + 'px'; + this.colShadow.style.width = shadowWidth + 'px'; + this.colShadowAdjust = event.pageX - shadowLeft; + this.cellSelector.currentDragType(selector.COL); + this.cellSelector.col.dropIndex(this.cellSelector.colLower()); +}; + +/** + * GridView.dragRows/dragCols update the row/col shadow and row/col indicator line on mousemove events. + * Rules for determining where the indicator line should show while dragging cols/rows: + * 0) The indicator line should not appear after the special add-row. + * 1) If the mouse position is within the selected range -> the indicator line should show + * at the left offset of the start of the select range + * 2) If the mouse position comes after the select range -> increment the computed dropIndex by 1 + * 3) If the last col/row is in the select range, the indicator line should be clamped to the start of the + * select range. + **/ +GridView.prototype.dragRows = function(elem, event) { + var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()), + this.getLastDataRowIndex()); + if (this.cellSelector.containsRow(dropIndex)) { + dropIndex = this.cellSelector.rowLower(); + } else if (dropIndex > this.cellSelector.rowUpper()) { + dropIndex += 1; + } + if (this.cellSelector.rowUpper() === this.viewData.peekLength - 1) { + dropIndex = Math.min(dropIndex, this.cellSelector.rowLower()); + } + + var linePos = this.scrolly.rowOffsetTree.getSumTo(dropIndex) + + this.header.getBoundingClientRect().height - this.scrollTop(); + this.cellSelector.row.linePos(linePos + 'px'); + this.cellSelector.row.dropIndex(dropIndex); + this.dragY(event.pageY); +}; + +GridView.prototype.dragCols = function(elem, event) { + var dropIndex = Math.min(this.getMousePosCol(event.pageX + this.scrollLeft()), + this.viewSection.viewFields().peekLength - 1); + if (this.cellSelector.containsCol(dropIndex)) { + dropIndex = this.cellSelector.colLower(); + } else if (dropIndex > this.cellSelector.colUpper()) { + dropIndex += 1; + } + if (this.cellSelector.colUpper() === this.viewSection.viewFields().peekLength - 1) { + dropIndex = Math.min(dropIndex, this.cellSelector.colLower()); + } + + var viewDataNumsWidth = $('.gridview_corner_spacer').width(); + var linePos = viewDataNumsWidth + this.colRightOffsets.peek().getSumTo(dropIndex) - this.scrollLeft(); + this.cellSelector.col.linePos(linePos + 'px'); + this.cellSelector.col.dropIndex(dropIndex); + this.dragX(event.pageX); +}; + +GridView.prototype.dropRows = function() { + var oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1); + this.moveRows(oldIndices, this.cellSelector.row.dropIndex()); +}; + +GridView.prototype.dropCols = function() { + var oldIndices = _.range(this.cellSelector.colLower(), this.cellSelector.colUpper() + 1); + const idx = this.cellSelector.col.dropIndex(); + this.moveColumns(oldIndices, idx); + // If this was a short click on a single already-selected column that results in no + // column movement, propose renaming the column. + if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 && + idx === oldIndices[0]) { + this.currentEditingColumnIndex(idx); + } + this._colClickTime = 0; +}; + +/** + * After rows/cols in the range start() to end() inclusive are moved to newIndex, + * update the start and end observables so that they stay selected after the move. + * @param {observable} start - observable denoting the start index of the moved/dropped elements + * @param {observable} end - observable denoting the end index of the moved/dropped elements + * @param {integer} numEles - number of elements to move + * @param {integer} newIndex - new index of the start of the selected range + */ +GridView.prototype._selectMovedElements = function(start, end, newIndex, numEles, elemType) { + console.assert(elemType === selector.ROW || elemType === selector.COL); + var newPos = newIndex < Math.min(start(), end()) ? newIndex : newIndex - numEles; + if (elemType === selector.COL) this.cursor.fieldIndex(newPos); + else if (elemType === selector.ROW) this.cursor.rowIndex(newPos); + + this.cellSelector.currentSelectType(elemType); + start(newPos); + end(newPos + numEles - 1); +}; + +// End of Dragging logic + + +// =========================================================================== +// CONTEXT MENUS + +GridView.prototype.columnContextMenu = function (ctl, selectedColIds, field, filterTriggerCtl) { + this.ctxMenuHolder.autoDispose(ctl); + const isReadonly = this.gristDoc.isReadonly.get(); + if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) { + return MultiColumnMenu({isReadonly}); + } else { + return ColumnContextMenu({ + disableModify: Boolean(field.disableModify.peek()), + filterOpenFunc: () => filterTriggerCtl.open(), + useNewUI: this.gristDoc.app.useNewUI, + sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(), + colId: field.column.peek().id.peek(), + isReadonly + }); + } +}; + +GridView.prototype._columnFilterMenu = function(ctl, field) { + this.ctxMenuHolder.autoDispose(ctl); + return this.createFilterMenu(ctl, field); +}; + +GridView.prototype.maybeSelectColumn = function (elem, field) { + const selectedColIds = this.getSelection().colIds; + if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) { + return; // No need to select the column because it's included in the multi-selection + } + this.assignCursor(elem, selector.COL); +}; + +GridView.prototype.maybeSelectRow = function(elem, rowId) { + // If the clicked row was not already in the selection, move the selection to the row. + if (!this.getSelection().rowIds.includes(rowId)) { + this.assignCursor(elem, selector.ROW); + } +}; + +// End Context Menus + +module.exports = GridView; diff --git a/app/client/components/GristDoc.css b/app/client/components/GristDoc.css new file mode 100644 index 00000000..ec1c634d --- /dev/null +++ b/app/client/components/GristDoc.css @@ -0,0 +1,69 @@ +/* container for main buttons */ +.g-doc-menu-main { + flex: 1; + -webkit-flex: 1; +} + +.btn.g_toolbar_symbol { + font-family: "Apple Symbols", "Arial Unicode MS"; + font-size: 2rem; + line-height: 15px; + padding-top: 4px; +} + +.big_symbol { + font-size: 2em; + line-height: 0.5; + vertical-align: middle; +} + +.grist-doc-menu__view-title { + margin: auto; /* center */ + text-align: center; +} + +.view_main_pane { + width: 100%; + position: relative; +} + +.view_main_pane.open_side_pane { + width: 75%; + min-width: 50%; +} + +.add_section_btn { + width: 9.5rem; + text-align: left; +} + +.add_section_icon { + position: relative; + background-color: white; + width: 1.6rem; + height: 1.2rem; + margin-left: 4px; +} + +.section_icon { + position: absolute; + font-size: 1.05rem; + top: .2rem; + left: 0; + transform: scale(.9, 1); +} +.plus_icon { + position: absolute; + top: .35rem; + left: 1.1rem; + font-size: .5rem; +} + +.download_btn { + font-size: 1.0rem; + color: black; +} + +.relative { + position: relative; +} diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts new file mode 100644 index 00000000..b3638e99 --- /dev/null +++ b/app/client/components/GristDoc.ts @@ -0,0 +1,693 @@ +/** + * GristDoc manages an open Grist document on the client side. + */ +// tslint:disable:no-console + +import {ActionLog} from 'app/client/components/ActionLog'; +import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel'; +import * as commands from 'app/client/components/commands'; +import {CursorPos} from 'app/client/components/Cursor'; +import {DocComm, DocUserAction} from 'app/client/components/DocComm'; +import * as DocConfigTab from 'app/client/components/DocConfigTab'; +import * as GridView from 'app/client/components/GridView'; +import {Importer} from 'app/client/components/Importer'; +import * as REPLTab from 'app/client/components/REPLTab'; +import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; +import {ViewLayout} from 'app/client/components/ViewLayout'; +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {DocPluginManager} from 'app/client/lib/DocPluginManager'; +import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; +import {createSessionObs} from 'app/client/lib/sessionObs'; +import {setTestState} from 'app/client/lib/testState'; +import {selectFiles} from 'app/client/lib/uploads'; +import {reportError} from 'app/client/models/AppModel'; +import * as DataTableModel from 'app/client/models/DataTableModel'; +import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff'; +import {DocData} from 'app/client/models/DocData'; +import {DocInfoRec, DocModel, ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {DocPageModel} from 'app/client/models/DocPageModel'; +import {UserError} from 'app/client/models/errors'; +import {IDocPage, urlState} from 'app/client/models/gristUrlState'; +import {QuerySetManager} from 'app/client/models/QuerySet'; +import {App} from 'app/client/ui/App'; +import {DocHistory} from 'app/client/ui/DocHistory'; +import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; +import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy'; +import {testId} from 'app/client/ui2018/cssVars'; +import {IconName} from 'app/client/ui2018/IconList'; +import {ActionGroup} from 'app/common/ActionGroup'; +import {delay} from 'app/common/delay'; +import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {isSchemaAction} from 'app/common/DocActions'; +import {OpenLocalDocResult} from 'app/common/DocListAPI'; +import {HashLink} from 'app/common/gristUrls'; +import {encodeQueryParams, waitObs} from 'app/common/gutil'; +import {StringUnion} from 'app/common/StringUnion'; +import {TableData} from 'app/common/TableData'; +import {DocStateComparison} from 'app/common/UserAPI'; +import {Computed, dom, Emitter, Holder, IDomComponent, subscribe, toKo} from 'grainjs'; +import {IDisposable, Observable, styled} from 'grainjs'; +import * as ko from 'knockout'; +import cloneDeepWith = require('lodash/cloneDeepWith'); +import isEqual = require('lodash/isEqual'); + +const G = getBrowserGlobals('document', 'window'); + +// Re-export DocComm to move it from main webpack bundle to the one with GristDoc. +export {DocComm}; + +export interface TabContent { + showObs?: any; + header?: boolean; + label?: any; + items?: any; + buildDom?: any; + keywords?: any; +} + +export interface TabOptions { + shortLabel?: string; + hideSearchContent?: boolean; + showObs?: any; + category?: any; +} + +const RightPanelTool = StringUnion("none", "docHistory", "validations", "repl"); + +export interface IExtraTool { + icon: IconName; + label: string; + content: TabContent[]|IDomComponent; +} + +export class GristDoc extends DisposableWithEvents { + public docModel: DocModel; + public viewModel: ViewRec; + public activeViewId: Computed; + public currentPageName: Observable; + public docData: DocData; + public docInfo: DocInfoRec; + public docPluginManager: DocPluginManager; + public querySetManager: QuerySetManager; + public rightPanelTool: Observable; + public isReadonly = this.docPageModel.isReadonly; + public isReadonlyKo = toKo(ko, this.isReadonly); + public comparison: DocStateComparison|null; + + // Emitter triggered when the main doc area is resized. + public readonly resizeEmitter = this.autoDispose(new Emitter()); + + // This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the + // previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at + // most one instance of FieldEditor at any time. + public readonly fieldEditorHolder = Holder.create(this); + + private _actionLog: ActionLog; + private _undoStack: UndoStack; + private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; + private _rightPanelTabs = new Map(); + private _docHistory: DocHistory; + private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); + private _viewLayout: ViewLayout|null = null; + + constructor( + public readonly app: App, + public readonly docComm: DocComm, + public readonly docPageModel: DocPageModel, + openDocResponse: OpenLocalDocResult, + options: { + comparison?: DocStateComparison // initial comparison with another document + } = {} + ) { + super(); + console.log("RECEIVED DOC RESPONSE", openDocResponse.doc); + this.docData = new DocData(this.docComm, openDocResponse.doc); + this.docModel = new DocModel(this.docData); + this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm); + this.docPluginManager = new DocPluginManager(openDocResponse.plugins, app.getUntrustedContentOrigin(), + this.docComm, app.clientScope); + + // Maintain the MetaRowModel for the global document info, including docId and peers. + this.docInfo = this.docModel.docInfo.getRowModel(1); + + const defaultViewId = this.docInfo.newDefaultViewId; + + // Grainjs observable for current view id, which may be a string such as 'code'. + this.activeViewId = Computed.create(this, urlState().state, (use, s) => s.docPage || defaultViewId.peek()); + + // This viewModel reflects the currently active view, relying on the fact that + // createFloatingRowModel() supports an observable rowId for its argument. + // Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values, + // which yield an empty row, which is why we can cast activeViewId. + this.viewModel = this.autoDispose( + this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed)); + + // Grainjs observable reflecting the name of the current document page. + this.currentPageName = Computed.create(this, this.activeViewId, + (use, docPage) => typeof docPage === 'number' ? use(this.viewModel.name) : docPage); + + // Whenever the active viewModel is deleted, switch to the default view. + this.autoDispose(this.viewModel._isDeleted.subscribe((isDeleted) => { + if (isDeleted) { + // This should not be done synchronously, as that affects the same viewModel that triggered + // this callback, and causes some obscure effects on knockout subscriptions. + Promise.resolve().then(() => urlState().pushUrl({docPage: undefined})).catch(() => null); + } + })); + + // Navigate to an anchor if one is present in the url hash. + this.autoDispose(subscribe(urlState().state, async (use, state) => { + if (state.hash) { + try { + const cursorPos = getCursorPosFromHash(state.hash); + await this._recursiveMoveToCursorPos(cursorPos, true, state.hash && state.hash.colRef); + } catch (e) { + reportError(e); + } finally { + setTimeout(finalizeAnchor, 0); + } + } + })); + + // Importer takes a function for creating previews. + const createPreview = (vs: ViewSectionRec) => GridView.create(this, vs, true); + + const importSourceElems = ImportSourceElement.fromArray(this.docPluginManager.pluginsList); + const importMenuItems = [ + { + label: 'Import from file', + action: () => Importer.selectAndImport(this, null, createPreview), + }, + ...importSourceElems.map(importSourceElem => ({ + label: importSourceElem.importSource.label, + action: () => Importer.selectAndImport(this, importSourceElem, createPreview) + })) + ]; + + // Set the available import sources in the DocPageModel. + this.docPageModel.importSources = importMenuItems; + + this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this })); + this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this })); + this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog); + + // Tap into docData's sendActions method to save the cursor position with every action, so that + // undo/redo can jump to the right place. + this.autoDispose(this.docData.sendActionsEmitter.addListener(this._onSendActionsStart, this)); + this.autoDispose(this.docData.sendActionsDoneEmitter.addListener(this._onSendActionsEnd, this)); + + /* Command binding */ + this.autoDispose(commands.createGroup({ + undo() { this._undoStack.sendUndoAction(); }, + redo() { this._undoStack.sendRedoAction(); }, + reloadPlugins() { this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); }, + }, this, true)); + + this.listenTo(app.comm, 'docUserAction', this.onDocUserAction); + + this.autoDispose(DocConfigTab.create({gristDoc: this})); + + const replTab = this.autoDispose(REPLTab.create(this)); + this.autoDispose(this.addOptionsTab( + 'REPL', dom('span.glyphicon.glyphicon-console'), + replTab.buildConfigDomObj(), + { hideSearchContent: true } + )); + + this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool))); + + this.comparison = options.comparison || null; + + // We need prevent default here to allow drop events to fire. + this.autoDispose(dom.onElem(window, 'dragover', (ev) => ev.preventDefault())); + // The default action is to open dragged files as a link, navigating out of the app. + this.autoDispose(dom.onElem(window, 'drop', (ev) => ev.preventDefault())); + } + + public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable { + this._rightPanelTabs.set(label, contentObj); + // Return a do-nothing disposable, to satisfy the previous interface. + return {dispose: () => null}; + } + + /** + * Builds the DOM for this GristDoc. + */ + public buildDom() { + return cssViewContentPane(testId('gristdoc'), + dom.domComputed(this.activeViewId, (viewId) => ( + viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) : + viewId === 'new' ? null : + dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId))) + )), + ); + } + + // Open the given page. Note that links to pages should use elements together with setLinkUrl(). + public openDocPage(viewId: IDocPage) { + return urlState().pushUrl({docPage: viewId}); + } + + public showTool(tool: typeof RightPanelTool.type): void { + this._rightPanelTool.set(tool); + } + + /** + * Returns an object representing the position of the cursor, including the section. It will have + * fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active. + */ + public getCursorPos(): CursorPos { + const pos = { sectionId: this.viewModel.activeSectionId() }; + const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek(); + return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {}); + } + + /** + * Switch to the view/section and scroll to the record indicated by cursorPos. If cursorPos is + * null, then moves to a position best suited for optActionGroup (not yet implemented). + */ + public moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: ActionGroup): void { + if (!cursorPos || cursorPos.sectionId == null) { + // TODO We could come up with a suitable cursorPos here based on the action itself. + // This should only come up if trying to undo/redo after reloading a page (since the cursorPos + // associated with the action is only stored in memory of the current JS process). + // A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best + // place from any action in the action log. + return; + } + + this._switchToSectionId(cursorPos.sectionId) + .then(viewInstance => (viewInstance && viewInstance.setCursorPos(cursorPos))) + .catch(reportError); + } + + /** + * Process actions received from the server by forwarding them to `docData.receiveAction()` and + * pushing them to actionLog. + */ + public onDocUserAction(message: DocUserAction) { + console.log("GristDoc.onDocUserAction", message); + let schemaUpdated = false; + if (this.docComm.isActionFromThisDoc(message)) { + const docActions = message.data.docActions; + for (let i = 0, len = docActions.length; i < len; i++) { + console.log("GristDoc applying #%d", i, docActions[i]); + this.docData.receiveAction(docActions[i]); + this.docPluginManager.receiveAction(docActions[i]); + + if (!schemaUpdated && isSchemaAction(docActions[i])) { + schemaUpdated = true; + } + } + // Add fromSelf property to actionGroup indicating if it's from the current session. + const actionGroup = message.data.actionGroup; + actionGroup.fromSelf = message.fromSelf || false; + // Push to the actionLog and the undoStack. + if (!actionGroup.internal) { + this._actionLog.pushAction(actionGroup); + this._undoStack.pushAction(actionGroup); + if (actionGroup.fromSelf) { + this._lastOwnActionGroup = actionGroup; + } + } + if (schemaUpdated) { + this.trigger('schemaUpdateAction', docActions); + } + } + } + + public getTableModel(tableId: string): DataTableModel { + return this.docModel.dataTables[tableId]; + } + + // Get a DataTableModel, possibly wrapped to include diff data if a comparison is + // in effect. + public getTableModelMaybeWithDiff(tableId: string): DataTableModel { + const tableModel = this.getTableModel(tableId); + if (!this.comparison?.details) { return tableModel; } + // TODO: cache wrapped models and share between views. + return new DataTableModelWithDiff(tableModel, this.comparison.details); + } + + /** + * Sends an action to create a new empty table and switches to that table's primary view. + */ + public async addEmptyTable(): Promise { + const tableInfo = await this.docData.sendAction(['AddEmptyTable']); + await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId()); + } + + /** + * Adds a view section described by val to the current page. + */ + public async addWidgetToPage(val: IPageWidget) { + const docData = this.docModel.docData; + const viewName = this.viewModel.name.peek(); + + const res = await docData.bundleActions( + `Added new linked section to view ${viewName}`, + () => this.addWidgetToPageImpl(val) + ); + + // The newly-added section should be given focus. + this.viewModel.activeSectionId(res.sectionRef); + } + + /** + * The actual implementation of addWidgetToPage + */ + public async addWidgetToPageImpl(val: IPageWidget) { + const viewRef = this.activeViewId.get(); + const tableRef = val.table === 'New Table' ? 0 : val.table; + const link = linkFromId(val.link); + + const result = await this.docData.sendAction( + ['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null] + ); + await this.docData.sendAction( + ['UpdateRecord', '_grist_Views_section', result.sectionRef, { + linkSrcSectionRef: link.srcSectionRef, + linkSrcColRef: link.srcColRef, + linkTargetColRef: link.targetColRef + }] + ); + return result; + } + + /** + * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`. + */ + public async addNewPage(val: IPageWidget) { + if (val.table === 'New Table') { + const result = await this.docData.sendAction(['AddEmptyTable']); + await this.openDocPage(result.views[0].id); + } else { + const result = await this.docData.sendAction( + ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null] + ); + await this.openDocPage(result.viewRef); + // The newly-added section should be given focus. + this.viewModel.activeSectionId(result.sectionRef); + } + } + + /** + * Opens a dialog to upload one or multiple files as tables and then switches to the first table's + * primary view. + */ + public async uploadNewTable(): Promise { + const uploadResult = await selectFiles({docWorkerUrl: this.docComm.docWorkerUrl, + multiple: true}); + if (uploadResult) { + const dataSource = {uploadId: uploadResult.uploadId, transforms: []}; + const importResult = await this.docComm.finishImportFiles(dataSource, {}, []); + const tableId = importResult.tables[0].hiddenTableId; + const tableRowModel = this.docModel.dataTables[tableId].tableMetaRow; + await this.openDocPage(tableRowModel.primaryViewId()); + } + } + + public async saveViewSection(section: ViewSectionRec, newVal: IPageWidget) { + const docData = this.docModel.docData; + const oldVal: IPageWidget = toPageWidget(section); + const viewModel = section.view(); + + if (isEqual(oldVal, newVal)) { + // nothing to be done + return; + } + + await this._viewLayout!.freezeUntil(docData.bundleActions( + `Saved linked section ${section.title()} in view ${viewModel.name()}`, + async () => { + + // if table changes or a table is made a summary table, let's replace the view section by a + // new one, and return. + if (oldVal.table !== newVal.table || oldVal.summarize !== newVal.summarize) { + await this._replaceViewSection(section, newVal); + return; + } + + // if type changes, let's save it. + if (oldVal.type !== newVal.type) { + await section.parentKey.saveOnly(newVal.type); + } + + // if grouped by column changes, let's use the specific user action. + if (!isEqual(oldVal.columns, newVal.columns)) { + await docData.sendAction( + ['UpdateSummaryViewSection', section.getRowId(), newVal.columns] + ); + } + + // update link + if (oldVal.link !== newVal.link) { + await this.saveLink(linkFromId(newVal.link)); + } + } + )); + } + + // Save link for the active section. + public async saveLink(link: IPageWidgetLink) { + const viewModel = this.viewModel; + return this.docData.sendAction( + ['UpdateRecord', '_grist_Views_section', viewModel.activeSection.peek().getRowId(), { + linkSrcSectionRef: link.srcSectionRef, + linkSrcColRef: link.srcColRef, + linkTargetColRef: link.targetColRef + }] + ); + } + + + // Returns the list of all the valid links to link from one of the sections in the active view to + // the page widget 'widget'. + public selectBy(widget: IPageWidget) { + const viewSections = this.viewModel.viewSections.peek().peek(); + return selectBy(this.docModel, viewSections, widget); + } + + // Fork the document if it is in prefork mode. + public async forkIfNeeded() { + if (this.docPageModel.isPrefork.get()) { + await this.docComm.forkAndUpdateUrl(); + } + } + + public getDownloadLink() { + return this.docComm.docUrl('download') + '?' + encodeQueryParams({ + doc: this.docPageModel.currentDocId.get(), + title: this.docPageModel.currentDocTitle.get(), + ...this.docComm.getUrlParams(), + }); + } + + public getCsvLink() { + return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({ + ...this.docComm.getUrlParams(), + title: this.docPageModel.currentDocTitle.get(), + viewSection: this.viewModel.activeSectionId(), + tableId: this.viewModel.activeSection().table().tableId(), + activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()) + }); + } + + private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool|null { + switch (tool) { + case 'docHistory': { + return {icon: 'Log', label: 'Document History', content: this._docHistory}; + } + case 'validations': { + const content = this._rightPanelTabs.get("Validate Data"); + return content ? {icon: 'Validation', label: 'Validation Rules', content} : null; + } + case 'repl': { + const content = this._rightPanelTabs.get("REPL"); + return content ? {icon: 'Repl', label: 'REPL', content} : null; + } + case 'none': + default: { + return null; + } + } + } + + private async _replaceViewSection(section: ViewSectionRec, newVal: IPageWidget) { + + const docModel = this.docModel; + const viewModel = section.view(); + const docData = this.docModel.docData; + + // we must read the current layout from the view layout because it can override the one in + // `section.layoutSpec` (in particular it provides a default layout when missing from the + // latter). + const layoutSpec = this._viewLayout!.layoutSpec(); + + const sectionTitle = section.title(); + const sectionId = section.id(); + + // create a new section + const sectionCreationResult = await this.addWidgetToPageImpl(newVal); + + // update section name + const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef); + await newSection.title.saveOnly(sectionTitle); + + // replace old section id with new section id in the layout spec and save + const newLayoutSpec = cloneDeepWith(layoutSpec, (val) => { + if (typeof val === 'object' && val.leaf === sectionId) { + return {...val, leaf: newSection.id()}; + } + }); + await viewModel.layoutSpec.saveOnly(JSON.stringify(newLayoutSpec)); + + // The newly-added section should be given focus. + this.viewModel.activeSectionId(newSection.getRowId()); + + // remove old section + await docData.sendAction(['RemoveViewSection', sectionId]); + } + + /** + * Helper called before an action is sent to the server. It saves cursor position to come back to + * in case of Undo. + */ + private _onSendActionsStart(ev: {cursorPos: CursorPos}) { + this._lastOwnActionGroup = null; + ev.cursorPos = this.getCursorPos(); + } + + /** + * Helper called when server responds to an action. It attaches the saved cursor position to the + * received action (if any), and stores also the resulting position. + */ + private _onSendActionsEnd(ev: {cursorPos: CursorPos}) { + const a = this._lastOwnActionGroup; + if (a) { + a.cursorPos = ev.cursorPos; + if (a.rowIdHint) { + a.cursorPos.rowId = a.rowIdHint; + } + } + } + + /** + * Switch to a given sectionId, wait for it to load, and return a Promise for the instantiated + * viewInstance (such as an instance of GridView or DetailView). + */ + private async _switchToSectionId(sectionId: number) { + const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId); + const view: ViewRec = section.view.peek(); + await this.openDocPage(view.getRowId()); + view.activeSectionId(sectionId); // this.viewModel will reflect this with a delay. + + // Returns the value of section.viewInstance() as soon as it is truthy. + return waitObs(section.viewInstance); + } + + /** + * Move to the desired cursor position. If colRef is supplied, the cursor will be + * moved to a field with that colRef. Any linked sections that need their cursors + * moved in order to achieve the desired outcome are handled recursively. + * If setAsActiveSection is true, the section in cursorPos is set as the current + * active section. + */ + private async _recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean, + colRef?: number): Promise { + try { + if (!cursorPos.sectionId) { throw new Error('sectionId required'); } + if (!cursorPos.rowId) { throw new Error('rowId required'); } + const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId); + const srcSection = section.linkSrcSection.peek(); + if (srcSection.id.peek()) { + // We're in a linked section, so we need to recurse to make sure the row we want + // will be visible. + const linkTargetCol = section.linkTargetCol.peek(); + let controller: any; + if (linkTargetCol.colId.peek()) { + const destTable = await this._getTableData(section); + controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek()); + } else { + controller = cursorPos.rowId; + } + const colId = section.linkSrcCol.peek().colId.peek(); + let srcRowId: any; + const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek(); + if (!colId && !isSrcSummary) { + // Simple case - source linked by rowId, not a summary. + srcRowId = controller; + } else { + const srcTable = await this._getTableData(srcSection); + if (!colId) { + // must be a summary -- otherwise dealt with earlier. + const destTable = await this._getTableData(section); + const filter: {[key: string]: any} = {}; + for (const c of srcSection.table.peek().columns.peek().peek()) { + if (c.summarySourceCol.peek()) { + const filterColId = c.summarySource.peek().colId.peek(); + const destValue = destTable.getValue(cursorPos.rowId, filterColId); + filter[filterColId] = destValue; + } + } + const result = srcTable.filterRecords(filter); // Should just have one record, or 0. + srcRowId = result[0] && result[0].id; + } else { + srcRowId = srcTable.findRow(colId, controller); + } + } + if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); } + await this._recursiveMoveToCursorPos({ + rowId: srcRowId, + sectionId: srcSection.id.peek() + }, false); + } + const view: ViewRec = section.view.peek(); + await this.openDocPage(view.getRowId()); + if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); } + const fieldIndex = colRef ? section.viewFields().peek().findIndex(f => f.colRef.peek() === colRef) : undefined; + const viewInstance = await waitObs(section.viewInstance); + if (!viewInstance) { throw new Error('view not found'); } + // Give any synchronous initial cursor setting a chance to happen. + await delay(0); + viewInstance.setCursorPos({...cursorPos, fieldIndex}); + // TODO: column selection not working on card/detail view, or getting overridden - + // look into it (not a high priority for now since feature not easily discoverable + // in this view). + } catch (e) { + console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`); + throw new UserError('There was a problem finding the desired cell.'); + } + } + + private async _getTableData(section: ViewSectionRec): Promise { + const viewInstance = await waitObs(section.viewInstance); + if (!viewInstance) { throw new Error('view not found'); } + await viewInstance.getLoadingDonePromise(); + const table = this.docData.getTable(section.table.peek().tableId.peek()); + if (!table) { throw new Error('no section table'); } + return table; + } +} + +/** + * Convert a url hash to a cursor position. + */ +function getCursorPosFromHash(hash: HashLink): CursorPos { + return { rowId: hash.rowId, sectionId: hash.sectionId }; +} + +async function finalizeAnchor() { + await urlState().pushUrl({ hash: {} }, { replace: true }); + setTestState({anchorApplied: true}); +} + +const cssViewContentPane = styled('div', ` + flex: auto; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + min-width: 240px; + margin: 12px; +`); diff --git a/app/client/components/GristWSConnection.ts b/app/client/components/GristWSConnection.ts new file mode 100644 index 00000000..cb01cfd4 --- /dev/null +++ b/app/client/components/GristWSConnection.ts @@ -0,0 +1,379 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {guessTimezone} from 'app/client/lib/guessTimezone'; +import {getWorker} from 'app/client/models/gristConfigCache'; +import * as gutil from 'app/common/gutil'; +import {addOrgToPath, docUrl, getGristConfig} from 'app/common/urlUtils'; +import {UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {Events as BackboneEvents} from 'backbone'; +import {Disposable} from 'grainjs'; + +const G = getBrowserGlobals('window'); +const reconnectInterval = [1000, 1000, 2000, 5000, 10000]; + +// Time that may elapse prior to triggering a heartbeat message. This is a message +// sent in order to keep the websocket from being closed by an intermediate load +// balancer. +const HEARTBEAT_PERIOD_IN_SECONDS = 45; + +// Find the correct worker to connect to for the currently viewed doc, +// returning a base url for endpoints served by that worker. The url +// may need to change again in future. +async function getDocWorkerUrl(assignmentId: string|null): Promise { + // Currently, a null assignmentId happens only in classic Grist, where the server + // never changes. + if (assignmentId === null) { return docUrl(null); } + + const api: UserAPI = new UserAPIImpl(getGristConfig().homeUrl!); + return getWorker(api, assignmentId); +} + +/** + * Settings for the Grist websocket connection. Includes timezone, urls, and client id, + * and various services needed for the connection. + */ +export interface GristWSSettings { + // A factory function for creating the WebSocket so that we can use from node + // or browser. + makeWebSocket(url: string): WebSocket; + + // A function for getting the timezone, so the code can be used outside webpack - + // currently a timezone library is lazy loaded in a way that doesn't quite work + // with ts-node. + getTimezone(): Promise; + + // Get the page url - this is how the organization is currently determined. + getPageUrl(): string; + + // Get the URL for the worker serving the given assignmentId (which is usually a docId). + getDocWorkerUrl(assignmentId: string|null): Promise; + + // Get an id associated with the client, null for "no id set yet". + getClientId(assignmentId: string|null): string|null; + + // Update the id associated with the client. Future calls to getClientId should return this. + updateClientId(assignmentId: string|null, clentId: string): void; + + // Returns the next identifier for a new GristWSConnection object, and advance the counter. + advanceCounter(): string; + + // Called with messages to log. + log(...args: any[]): void; + warn(...args: any[]): void; +} + +/** + * An implementation of Grist websocket connection settings for the browser. + */ +export class GristWSSettingsBrowser implements GristWSSettings { + public makeWebSocket(url: string) { return new WebSocket(url); } + public getTimezone() { return guessTimezone(); } + public getPageUrl() { return G.window.location.href; } + public async getDocWorkerUrl(assignmentId: string|null) { + return getDocWorkerUrl(assignmentId); + } + public getClientId(assignmentId: string|null) { + return window.sessionStorage.getItem(`clientId_${assignmentId}`) || null; + } + public updateClientId(assignmentId: string|null, id: string) { + window.sessionStorage.setItem(`clientId_${assignmentId}`, id); + } + public advanceCounter(): string { + const value = parseInt(window.sessionStorage.getItem('clientCounter')!, 10) || 0; + window.sessionStorage.setItem('clientCounter', String(value + 1)); + return String(value); + } + public log(...args: any[]): void { + console.log(...args); // tslint:disable-line:no-console + } + public warn(...args: any[]): void { + console.warn(...args); // tslint:disable-line:no-console + } +} + +/** + * GristWSConnection establishes a connection to the server and keep reconnecting + * in the event that it loses the connection. + */ +export class GristWSConnection extends Disposable { + public useCount: number = 0; + public on: BackboneEvents['on']; // set by Backbone + + private _clientId: string|null; + private _clientCounter: string; // Identifier of this GristWSConnection object in this browser tab session + private _assignmentId: string|null; + private _docWorkerUrl: string|null = null; + private _initialConnection: Promise; + private _established: boolean = false; // This is set once the server sends us a 'clientConnect' message. + private _firstConnect: boolean = true; + private _heartbeatTimeout: ReturnType | null = null; + private _reconnectTimeout: ReturnType | null = null; + private _reconnectAttempts: number = 0; + private _wantReconnect: boolean = true; + private _ws: WebSocket|null = null; + private trigger: BackboneEvents['trigger']; // set by Backbone + + constructor(private _settings: GristWSSettings = new GristWSSettingsBrowser()) { + super(); + this._clientCounter = _settings.advanceCounter(); + this.onDispose(() => this.disconnect()); + } + + public initialize(assignmentId: string|null) { + // For reconnections, squirrel away the id of the resource we are committed to (if any). + this._assignmentId = assignmentId; + // clientId is associated with a session. We try to persist it within a tab across navigation + // and reloads, but the server may reset it if it doesn't recognize it. + this._clientId = this._settings.getClientId(assignmentId); + // For the DocMenu, identified as a page served with a homeUrl but no getWorker cache, we will + // simply not hook up the websocket. The client is not ready to use it, and the server is not + // ready to serve it. And the errors in the logs of both are distracting. However, it + // doesn't really make sense to rip out the websocket code entirely, since the plan is + // to eventually bring it back for smoother serving. Hence this compromise of simply + // not trying to make the connection. + // TODO: serve and use websockets for the DocMenu. + if (getGristConfig().getWorker) { + this.trigger('connectState', false); + this._initialConnection = this.connect(); + } else { + this._log("GristWSConnection not activating for hosted grist page with no document present"); + } + } + + /** + * Method that opens a websocket connection and continuously tries to reconnect if the connection + * is closed. + * @param isReconnecting - Flag set when attempting to reconnect + */ + public async connect(isReconnecting: boolean = false): Promise { + await this._updateDocWorkerUrl(); + this._wantReconnect = true; + this._connectImpl(isReconnecting, await this._settings.getTimezone()); + } + + // Disconnect websocket if currently connected, and reset to initial state. + public disconnect() { + this._log('GristWSConnection: disconnect'); + this._wantReconnect = false; + this._established = false; + if (this._ws) { + this._ws.close(); + this._ws = null; + this._clientId = null; + } + this._clearHeartbeat(); + if (this._reconnectTimeout) { + clearTimeout(this._reconnectTimeout); + } + this._firstConnect = true; + this._reconnectAttempts = 0; + } + + public get established(): boolean { + return this._established; + } + + public get clientId(): string|null { + return this._clientId; + } + + /** + * Returns the URL of the doc worker, or throws if we don't have one. + */ + public get docWorkerUrl(): string { + if (!this._docWorkerUrl) { throw new Error('server for document not known'); } + return this._docWorkerUrl; + } + + /** + * Returns the URL of the doc worker, or null if we don't have one. + */ + public getDocWorkerUrlOrNull(): string | null { + return this._docWorkerUrl; + } + + /** + * @event serverMessage Triggered when a message arrives from the server. Callbacks receive + * the raw message data as an additional argument. + */ + public onmessage(ev: any) { + this._log('GristWSConnection: onmessage (%d bytes)', ev.data.length); + this._scheduleHeartbeat(); + const message = JSON.parse(ev.data); + + // clientConnect is the first message from the server that sets the clientId. We only consider + // the connection established once we receive it. + if (message.type === 'clientConnect') { + if (this._established) { + this._log("GristWSConnection skipping duplicate 'clientConnect' message"); + return; + } + this._established = true; + // Add a flag to the message to indicate if the active session changed, and warrants a reload. + message.resetClientId = (message.clientId !== this._clientId && !this._firstConnect); + this._log(`GristWSConnection established: clientId ${message.clientId} counter ${this._clientCounter}` + + ` resetClientId ${message.resetClientId}`); + if (message.dup) { + this._warn("GristWSConnection missed initial 'clientConnect', processing its duplicate"); + } + if (message.clientId !== this._clientId) { + this._clientId = message.clientId; + if (this._settings) { + this._settings.updateClientId(this._assignmentId, message.clientId); + } + } + this._firstConnect = false; + this.trigger('connectState', true); + + // Process any missed messages. (Should only have any if resetClientId is false.) + for (const msg of message.missedMessages) { + this.trigger('serverMessage', JSON.parse(msg)); + } + } + if (!this._established) { + this._log("GristWSConnection not yet established; ignoring message", message); + return; + } + this.trigger('serverMessage', message); + } + + public send(message: any) { + this._log(`GristWSConnection.send[${this.established}]`, message); + if (!this._established) { + return false; + } + this._ws!.send(message); + this._scheduleHeartbeat(); + return true; + } + + // unschedule any pending heartbeat message + private _clearHeartbeat() { + if (this._heartbeatTimeout) { + clearTimeout(this._heartbeatTimeout); + this._heartbeatTimeout = null; + } + } + + // schedule a heartbeat message for HEARTBEAT_PERIOD_IN_SECONDS seconds from now + private _scheduleHeartbeat() { + this._clearHeartbeat(); + this._heartbeatTimeout = setTimeout(this._sendHeartbeat.bind(this), + Math.round(HEARTBEAT_PERIOD_IN_SECONDS * 1000)); + } + + // send a heartbeat message, including the document url for server-side logs + private _sendHeartbeat() { + this.send(JSON.stringify({ + beat: 'alive', + url: G.window.location.href, + })); + } + + private _connectImpl(isReconnecting: boolean, timezone: any) { + + if (!this._wantReconnect) { return; } + + if (isReconnecting) { + this._reconnectAttempts++; + } + + // Note that if a WebSocket can't establish a connection it will trigger onclose() + // As per http://dev.w3.org/html5/websockets/ + // "If the establish a WebSocket connection algorithm fails, + // it triggers the fail the WebSocket connection algorithm, + // which then invokes the close the WebSocket connection algorithm, + // which then establishes that the WebSocket connection is closed, + // which fires the close event." + const url = this._buildWebsocketUrl(isReconnecting, timezone); + this._log("GristWSConnection connecting to: " + url); + this._ws = this._settings.makeWebSocket(url); + + this._ws.onopen = () => { + const connectMessage = isReconnecting ? 'Reconnected' : 'Connected'; + this._log('GristWSConnection: onopen: ' + connectMessage); + + this.trigger('connectionStatus', connectMessage, 'OK'); + this._reconnectAttempts = 0; // reset reconnection information + this._scheduleHeartbeat(); + }; + + this._ws.onmessage = this.onmessage.bind(this); + + this._ws.onerror = (ev: Event) => { + this._log('GristWSConnection: onerror', ev); + }; + + this._ws.onclose = () => { + if (this._settings) { + this._log('GristWSConnection: onclose'); + } + if (this.isDisposed()) { + return; + } + + this._established = false; + this._ws = null; + this.trigger('connectState', false); + + if (!this._wantReconnect) { return; } + const reconnectTimeout = gutil.getReconnectTimeout(this._reconnectAttempts, reconnectInterval); + this._log("Trying to reconnect in", reconnectTimeout, "ms"); + this.trigger('connectionStatus', 'Trying to reconnect...', 'WARNING'); + this._reconnectTimeout = setTimeout(async () => { + this._reconnectTimeout = null; + // Make sure we've gotten through all lazy-loading. + await this._initialConnection; + await this.connect(true); + }, reconnectTimeout); + }; + } + + private _buildWebsocketUrl(isReconnecting: boolean, timezone: any): string { + const url = new URL(this.docWorkerUrl); + url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:'; + url.searchParams.append('clientId', this._clientId || '0'); + url.searchParams.append('counter', this._clientCounter); + url.searchParams.append('newClient', String(isReconnecting ? 0 : 1)); + url.searchParams.append('browserSettings', JSON.stringify({timezone})); + return url.href; + } + + private async _updateDocWorkerUrl() { + try { + const url: string|null = await this._settings.getDocWorkerUrl(this._assignmentId); + // Doc worker urls in general will need to have org information in them, since + // the doc worker will check for that. The home service doesn't currently do + // that for us, although it could. TODO: update home server to produce + // standalone doc worker urls. + this._docWorkerUrl = url ? addOrgToPath(url, this._settings.getPageUrl()) : url; + } catch (e) { + this._warn('Failed to connect to server for document'); + } + } + + // Log a message using the configured logger, or send it to console if no + // logger available. + private _log(...args: any[]): void { + if (!this._settings) { + // tslint:disable-next-line:no-console + console.warn('log called without settings in GristWSConnection'); + console.log(...args); // tslint:disable-line:no-console + } else { + this._settings.log(...args); + } + } + + // Log a warning using the configured logger, or send it to console if no + // logger available. + private _warn(...args: any[]): void { + if (!this._settings) { + // tslint:disable-next-line:no-console + console.warn('warn called without settings in GristWSConnection'); + console.warn(...args); // tslint:disable-line:no-console + } else { + this._settings.warn(...args); + } + } +} + +Object.assign(GristWSConnection.prototype, BackboneEvents); diff --git a/app/client/components/Importer.ts b/app/client/components/Importer.ts new file mode 100644 index 00000000..1dddd38e --- /dev/null +++ b/app/client/components/Importer.ts @@ -0,0 +1,509 @@ +/** + * Importer manages an import files to Grist tables + * TODO: hidden tables should be also deleted on page refresh, error... + */ +// tslint:disable:no-console + +import {GristDoc} from "app/client/components/GristDoc"; +import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions'; +import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; +import {fetchURL, selectFiles, uploadFiles} from 'app/client/lib/uploads'; +import {reportError} from 'app/client/models/AppModel'; +import {ViewSectionRec} from 'app/client/models/DocModel'; +import {openFilePicker} from "app/client/ui/FileDialog"; +import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {loadingSpinner} from 'app/client/ui2018/loaders'; +import {IOptionFull, linkSelect} from 'app/client/ui2018/menus'; +import {cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals'; +import {DataSourceTransformed, ImportResult, ImportTableResult} from "app/common/ActiveDocAPI"; +import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI"; +import {byteString} from "app/common/gutil"; +import {UploadResult} from 'app/common/uploads'; +import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI'; +import {RenderTarget} from 'app/plugin/RenderOptions'; +import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs'; + +// Special values for import destinations; null means "new table". +// TODO We should also support "skip table" (needs server support), so that one can open, say, +// an Excel file with many tabs, and import only some of them. +type DestId = string | null; + +// We expect a function for creating the preview GridView, to avoid the need to require the +// GridView module here. That brings many dependencies, making a simple test fixture difficult. +type CreatePreviewFunc = (vs: ViewSectionRec) => GridView; +type GridView = IDisposable & {viewPane: HTMLElement}; + +// SourceInfo conteains information about source table and corresponding destination table id, +// transform sectionRef (can be used to show previous transform section with users changes) +// and also originalFilename and path. +export interface SourceInfo { + hiddenTableId: string; + uploadFileIndex: number; + origTableName: string; + sourceSection: ViewSectionRec; + transformSection: Observable; + destTableId: Observable; +} + +/** + * Importer manages an import files to Grist tables and shows Preview + */ +export class Importer extends Disposable { + /** + * Imports using the given plugin importer, or the built-in file-picker when null is passed in. + */ + public static async selectAndImport( + gristDoc: GristDoc, importSourceElem: ImportSourceElement|null, createPreview: CreatePreviewFunc + ) { + // In case of using built-in file picker we want to get upload result before instantiating Importer + // because if the user dismisses the dialog without picking a file, + // there is no good way to detect this and dispose Importer. + let uploadResult: UploadResult|null = null; + if (!importSourceElem) { + // Use the built-in file picker. On electron, it uses the native file selector (without + // actually uploading anything), which is why this requires a slightly different flow. + const files: File[] = await openFilePicker({multiple: true}); + // Important to fork first before trying to import, so we end up uploading to a + // consistent doc worker. + await gristDoc.forkIfNeeded(); + const label = files.map(f => f.name).join(', '); + const size = files.reduce((acc, f) => acc + f.size, 0); + const app = gristDoc.app.topAppModel.appObs.get(); + const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null; + const onProgress = (percent: number) => progress && progress.setProgress(percent); + try { + onProgress(0); + uploadResult = await uploadFiles(files, {docWorkerUrl: gristDoc.docComm.docWorkerUrl, + sizeLimit: 'import'}, onProgress); + onProgress(100); + } finally { + if (progress) { + progress.dispose(); + } + } + } + // Importer disposes itself when its dialog is closed, so we do not take ownership of it. + Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(uploadResult) + .catch((err) => reportError(err)); + } + + private _docComm = this._gristDoc.docComm; + private _uploadResult?: UploadResult; + private _openModalCtl: IModalControl|null = null; + private _importerContent = Observable.create(this, null); + + private _parseOptions = Observable.create(this, {}); + private _sourceInfoArray = Observable.create(this, []); + private _sourceInfoSelected = Observable.create(this, null); + + private _previewViewSection: Observable = + Computed.create(this, this._sourceInfoSelected, (use, info) => { + if (!info) { return null; } + const viewSection = use(info.transformSection); + return viewSection && !use(viewSection._isDeleted) ? viewSection : null; + }); + + // destTables is a list of options for import destinations, and includes all tables in the + // document, plus two values: to import as a new table, and to skip an import table entirely. + private _destTables = Computed.create>>(this, (use) => [ + {value: null, label: 'New Table'}, + ...use(this._gristDoc.docModel.allTableIds.getObservable()).map((t) => ({value: t, label: t})), + ]); + + // null tells to use the built-in file picker. + constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null, + private _createPreview: CreatePreviewFunc) { + super(); + } + + /* + * Get new import sources and update the current one. + */ + public async pickAndUploadSource(uploadResult: UploadResult|null) { + try { + if (!this._importSourceElem) { + // Use upload result if it was passed in or the built-in file picker. + // On electron, it uses the native file selector (without actually uploading anything), + // which is why this requires a slightly different flow. + uploadResult = uploadResult || await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl, + multiple: true, sizeLimit: 'import'}); + } else { + const plugin = this._importSourceElem.plugin; + + // registers a render target for plugin to render inline. + const handle: RenderTarget = plugin.addRenderTarget((el, opt = {}) => { + el.style.width = "100%"; + el.style.height = opt.height || "200px"; + this._showImportDialog(); + this._renderPlugin(el); + }); + + const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle); + plugin.removeRenderTarget(handle); + + if (importSource) { + // If data has been picked, upload it. + const item = importSource.item; + if (item.kind === "fileList") { + const files = item.files.map(({content, name}) => new File([content], name)); + uploadResult = await uploadFiles(files, {docWorkerUrl: this._docComm.docWorkerUrl, + sizeLimit: 'import'}); + } else if (item.kind === "url") { + uploadResult = await fetchURL(this._docComm, item.url); + } else { + throw new Error(`Import source of kind ${item!.kind} are not yet supported!`); + } + } + } + } catch (err) { + this._renderError(err.message); + return; + } + + if (uploadResult) { + this._uploadResult = uploadResult; + await this._reImport(uploadResult); + } else { + await this._cancelImport(); + } + } + + private _getPrimaryViewSection(tableId: string): ViewSectionRec { + const tableModel = this._gristDoc.getTableModel(tableId); + const viewRow = tableModel.tableMetaRow.primaryView.peek(); + return viewRow.viewSections.peek().peek()[0]; + } + + private _getSectionByRef(sectionRef: number): ViewSectionRec { + return this._gristDoc.docModel.viewSections.getRowModel(sectionRef); + } + + private async _updateTransformSection(sourceInfo: SourceInfo, destTableId: string|null) { + const transformSectionRef = await this._gristDoc.docData.sendAction( + ['GenImporterView', sourceInfo.hiddenTableId, destTableId, null]); + sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef)); + sourceInfo.destTableId.set(destTableId); + } + + private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed { + const transforms: TransformRuleMap[] = upload.files.map((file, i) => this._createTransformRuleMap(i)); + return {uploadId: upload.uploadId, transforms}; + } + + private _createTransformRuleMap(uploadFileIndex: number): TransformRuleMap { + const result: TransformRuleMap = {}; + for (const sourceInfo of this._sourceInfoArray.get()) { + if (sourceInfo.uploadFileIndex === uploadFileIndex) { + result[sourceInfo.origTableName] = this._createTransformRule(sourceInfo); + } + } + return result; + } + + private _createTransformRule(sourceInfo: SourceInfo): TransformRule { + const transformFields = sourceInfo.transformSection.get().viewFields().peek(); + const sourceFields = sourceInfo.sourceSection.viewFields().peek(); + + const destTableId: DestId = sourceInfo.destTableId.get(); + return { + destTableId, + destCols: transformFields.map((field) => ({ + label: field.label(), + colId: destTableId ? field.colId() : null, // if inserting into new table, colId isnt defined + type: field.column().type(), + formula: field.column().formula() + })), + sourceCols: sourceFields.map((field) => field.colId()) + }; + } + + private _getHiddenTableIds(): string[] { + return this._sourceInfoArray.get().map((t: SourceInfo) => t.hiddenTableId); + } + + private async _reImport(upload: UploadResult) { + this._renderSpinner(); + this._showImportDialog(); + try { + const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100}; + const importResult: ImportResult = await this._docComm.importFiles( + this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds()); + + this._parseOptions.set(importResult.options); + + this._sourceInfoArray.set(importResult.tables.map((info: ImportTableResult) => ({ + hiddenTableId: info.hiddenTableId, + uploadFileIndex: info.uploadFileIndex, + origTableName: info.origTableName, + sourceSection: this._getPrimaryViewSection(info.hiddenTableId)!, + transformSection: Observable.create(null, this._getSectionByRef(info.transformSectionRef)), + destTableId: Observable.create(null, info.destTableId) + }))); + + if (this._sourceInfoArray.get().length === 0) { + throw new Error("No data was imported"); + } + + // Select the first sourceInfo to show in preview. + this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null); + + this._renderMain(upload); + + } catch (e) { + console.warn("Import failed", e); + this._renderError(e.message); + } + } + + private async _finishImport(upload: UploadResult) { + this._renderSpinner(); + const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0}; + const importResult: ImportResult = await this._docComm.finishImportFiles( + this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds()); + + if (importResult.tables[0].hiddenTableId) { + const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow; + await this._gristDoc.openDocPage(tableRowModel.primaryViewId()); + } + this._openModalCtl!.close(); + this.dispose(); + } + + private async _cancelImport() { + if (this._uploadResult) { + await this._docComm.cancelImportFiles( + this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds()); + } + this._openModalCtl!.close(); + this.dispose(); + } + + private _showImportDialog() { + if (this._openModalCtl) { return; } + modal((ctl, owner) => { + this._openModalCtl = ctl; + return [ + cssModalOverrides.cls(''), + dom.domComputed(this._importerContent), + testId('importer-dialog'), + ]; + }, { + noClickAway: true, + noEscapeKey: true, + }); + } + + private _buildModalTitle(rightElement?: DomContents) { + const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file'; + return cssModalHeader(cssModalTitle(title), rightElement); + } + + // The importer state showing just a spinner, when the user has to wait. We don't even let the + // user cancel it, because the cleanup can only happen properly once the wait completes. + private _renderSpinner() { + this._importerContent.set([this._buildModalTitle(), cssSpinner(loadingSpinner())]); + } + + // The importer state showing the inline element from the plugin (e.g. to enter URL in case of + // import-from-url). + private _renderPlugin(inlineElement: HTMLElement) { + this._importerContent.set([this._buildModalTitle(), inlineElement]); + } + + // The importer state showing just an error. + private _renderError(message: string) { + this._importerContent.set([ + this._buildModalTitle(), + cssModalBody('Import failed: ', message, testId('importer-error')), + cssModalButtons( + bigBasicButton('Close', + dom.on('click', () => this._cancelImport()), + testId('modal-cancel'), + ), + ), + ]); + } + + // The importer state showing import in progress, with a list of tables, and a preview. + private _renderMain(upload: UploadResult) { + const schema = this._parseOptions.get().SCHEMA; + this._importerContent.set([ + this._buildModalTitle( + schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options', + testId('importer-options-link'), + dom.on('click', () => this._renderParseOptions(schema, upload)) + ) : null, + ), + cssPreviewWrapper( + cssTableList( + dom.forEach(this._sourceInfoArray, (info) => { + const destTableId = Computed.create(null, (use) => use(info.destTableId)) + .onWrite((destId) => this._updateTransformSection(info, destId)); + return cssTableInfo( + dom.autoDispose(destTableId), + cssTableLine(cssToFrom('From'), + cssTableSource(getSourceDescription(info, upload), testId('importer-from'))), + cssTableLine(cssToFrom('To'), linkSelect(destTableId, this._destTables)), + cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info), + dom.on('click', () => this._sourceInfoSelected.set(info)), + testId('importer-source'), + ); + }), + ), + dom.maybe(this._previewViewSection, () => cssSectionHeader('Preview')), + dom.maybe(this._previewViewSection, (viewSection) => { + const gridView = this._createPreview(viewSection); + return cssPreviewGrid( + dom.autoDispose(gridView), + gridView.viewPane, + testId('importer-preview'), + ); + }), + ), + cssModalButtons( + bigPrimaryButton('Import', + dom.on('click', () => this._finishImport(upload)), + testId('modal-confirm'), + ), + bigBasicButton('Cancel', + dom.on('click', () => this._cancelImport()), + testId('modal-cancel'), + ), + ), + ]); + } + + // The importer state showing parse options that may be changed. + private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) { + this._importerContent.set([ + this._buildModalTitle(), + dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues, + (p: ParseOptions) => { + this._parseOptions.set(p); + this._reImport(upload).catch((err) => reportError(err)); + }, + () => { this._renderMain(upload); }, + ) + ]); + } +} + +function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) { + const origName = upload!.files[sourceInfo.uploadFileIndex].origName; + return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName; +} + + +const cssActionLink = styled('div', ` + display: inline-flex; + align-items: center; + cursor: pointer; + color: ${colors.lightGreen}; + --icon-color: ${colors.lightGreen}; + &:hover { + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; + } +`); + +const cssLinkIcon = styled(icon, ` + flex: none; + margin-right: 4px; +`); + +const cssModalOverrides = styled('div', ` + max-height: calc(100% - 32px); + display: flex; + flex-direction: column; + & > .${cssModalButtons.className} { + margin-top: 16px; + } +`); + +const cssModalBody = styled('div', ` + padding: 16px 0; + overflow-y: auto; + max-width: 470px; +`); + +const cssSpinner = styled('div', ` + display: flex; + align-items: center; + height: 80px; + margin: auto; +`); + +const cssModalHeader = styled('div', ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + & > .${cssModalTitle.className} { + margin-bottom: 0px; + } +`); + +const cssPreviewWrapper = styled('div', ` + width: 600px; + padding: 8px 12px 8px 0; + overflow-y: auto; +`); + +// This partly duplicates cssSectionHeader from HomeLeftPane.ts +const cssSectionHeader = styled('div', ` + margin-bottom: 8px; + color: ${colors.slate}; + text-transform: uppercase; + font-weight: 500; + font-size: ${vars.xsmallFontSize}; + letter-spacing: 1px; +`); + +const cssTableList = styled('div', ` + display: flex; + flex-flow: row wrap; + justify-content: space-between; + margin-bottom: 16px; + align-items: flex-start; +`); + +const cssTableInfo = styled('div', ` + padding: 4px 8px; + margin: 4px 0px; + width: calc(50% - 16px); + border-radius: 3px; + border: 1px solid ${colors.darkGrey}; + &:hover, &-selected { + background-color: ${colors.mediumGrey}; + } +`); + +const cssTableLine = styled('div', ` + display: flex; + align-items: center; + margin: 4px 0; +`); + +const cssToFrom = styled('span', ` + flex: none; + margin-right: 8px; + color: ${colors.slate}; + text-transform: uppercase; + font-weight: 500; + font-size: ${vars.xsmallFontSize}; + letter-spacing: 1px; + width: 40px; + text-align: right; +`); + +const cssTableSource = styled('div', ` + overflow-wrap: anywhere; +`); + +const cssPreviewGrid = styled('div', ` + display: flex; + height: 300px; + border: 1px solid ${colors.darkGrey}; +`); diff --git a/app/client/components/Layout.css b/app/client/components/Layout.css new file mode 100644 index 00000000..085a1beb --- /dev/null +++ b/app/client/components/Layout.css @@ -0,0 +1,89 @@ +.layout_root { + position: relative; + width: 100%; + height: 100%; +} + +.layout_root.layout_fill_window { + position: absolute; +} + +.layout_root > .layout_box { + height: 100%; +} + +.layout_box { + position: relative; + display: -webkit-flex; + display: flex; + min-width: 0px; +} + +.layout_hbox.layout_fill_window { + -webkit-flex: 1 1 0px; + flex: 1 1 0px; +} + +/* We can't use ':last-child' because of resize-handle elements tacked on beyond it. */ +.layout_hbox.layout_last_child { + -webkit-flex: 1 1 auto; + flex: 1 1 auto; +} + +.layout_vbox { + -webkit-flex-direction: column; + flex-direction: column; + -webkit-flex: 1 1 0px; + flex: 1 1 0px; +} + +/* not so much to specify the look, as to simplify filtering events */ +.layout_leaf { + -webkit-flex-direction: column; + flex-direction: column; +} + +.layout_new, .layout_trash { + min-height: 2rem; + line-height: 2rem; + padding: 0.5rem 1rem; + margin: 0.5rem 0; + cursor: default; +} + +.layout_trash:hover, .layout_new:hover { + background-color: #F8F8F8; +} + +.layout_new { + border-left: 1px solid lightgrey; + border-top: 1px solid lightgrey; + border-right: 1px solid grey; + border-bottom: 1px solid grey; + color: grey; +} + +.layout_trash { + border: 1px solid lightgrey; + border-radius: 3px; + color: red; +} + +.layout_leaf_test { + border-left: 1px solid lightgrey; + border-top: 1px solid lightgrey; + border-right: 1px solid grey; + border-bottom: 1px solid grey; + color: grey; + width: 100%; + -webkit-flex: 1 1 0px; + flex: 1 1 0px; + min-height: 5rem; + line-height: 5rem; + justify-content: center; + text-align: center; +} + +.layout_leaf_test_big { + min-height: 7rem; +} diff --git a/app/client/components/Layout.js b/app/client/components/Layout.js new file mode 100644 index 00000000..e29b02ea --- /dev/null +++ b/app/client/components/Layout.js @@ -0,0 +1,472 @@ +/** + * This module provides the ability to render and edit hierarchical layouts of boxes. Each box may + * contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with + * the depth in the hierarchy. + * + * Layout + * Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in + * alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that + * may be moved around, with the structure of Boxes above them changing to accommodate. + * + * LayoutBox + * A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual + * about them (e.g. no borders) except their dimensions: they serve purely for layout purposes. + * + * A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row. + * A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain + * a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox + * may contain a single LayoutBox as a child: it must contain either multiple LayoutBox + * children, or a single LeafBox. + * + * LeafBox + * A LeafBox is the container for user content, i.e. what needs to be laid out, for example + * form elements. LeafBoxes are what the user can drag around to other location in the layout. + * All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of + * the layout are to be empty, they should still contain an empty LeafBox. + * + * There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf + * class and set leafId and leafContent member observables. + * + * Floater + * A Floater is a rectangle that floats over the layout with the mouse pointer while the user is + * dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user + * can see what is being repositioned. + * + * DropOverlay + * An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a + * drop may be attempted. It also computes the "affinity": which border of the current LeafBox + * the user is trying to target as the insertion point. + * + * DropTargeter + * DropTargeter displays a set of rectangles, each of which represents a particular allowed + * insertion point for the element being dragged. E.g. dragging an element to the right side of + * a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling + * to be inserted on the right. + * + * Saving Changes + * -------------- + * We don't attempt to save granular changes to the layout, for each drag operation, because + * for the user, it's better to finish editing the layout, and only save the end result. Also, + * it's not so easy (the structure changes many times while dragging, and a single drag + * operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we + * just have a way to serialize the layout to and from a JSON blob. + */ + + +var ko = require('knockout'); +var assert = require('assert'); +var _ = require('underscore'); +var BackboneEvents = require('backbone').Events; + +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var koArray = require('../lib/koArray'); + +/** + * A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for + * rendering as well as for the code editor. Since it may be rendered many times on a page, it's + * important for it to be efficient. + * @param {Layout} layout: The Layout object that manages this LayoutBox. + */ +function LayoutBox(layout) { + this.layout = layout; + this.parentBox = ko.observable(null); + this.childBoxes = koArray(); + this.leafId = ko.observable(null); + this.leafContent = ko.observable(null); + this.uniqueId = _.uniqueId("lb"); // For logging and debugging. + + this.isVBox = this.autoDispose(ko.computed(function() { + return this.parentBox() ? !this.parentBox().isVBox() : true; + }, this)); + this.isHBox = this.autoDispose(ko.computed(function() { return !this.isVBox(); }, this)); + this.isLeaf = this.autoDispose(ko.computed(function() { return this.leafId() !== null; }, + this)); + + // flexSize represents flexWidth for VBoxes and flexHeight for HBoxes. + // Undesirable transition effects are likely when <1, so we set average value + // to 100 so that reduction below 1 is rare. + this.flexSize = ko.observable(100); + + this.dom = null; + + this._parentBeingDisposed = false; + + // This is an optimization to avoid the wasted cost of removeFromParent during disposal. + this._parentBeingDisposed = false; + + this.autoDisposeCallback(function() { + if (!this._parentBeingDisposed) { + this.removeFromParent(); + } + this.childBoxes.peek().forEach(function(child) { + child._parentBeingDisposed = true; + child.dispose(); + }); + }); +} +exports.LayoutBox = LayoutBox; +dispose.makeDisposable(LayoutBox); + +LayoutBox.prototype.getDom = function() { + return this.dom || (this.dom = this.autoDispose(this.buildDom())); +}; + + +/** + * This helper turns a value, observable, or function (as accepted by koDom functions) into a + * plain value. It's used to build a static piece of DOM without subscribing to any of the + * observables, to avoid the performance cost of subscribing/unsubscribing. + */ +function makeStatic(valueOrFunc) { + if (ko.isObservable(valueOrFunc) || koArray.isKoArray(valueOrFunc)) { + return valueOrFunc.peek(); + } else if (typeof valueOrFunc === 'function') { + return valueOrFunc(); + } else { + return valueOrFunc; + } +} + +LayoutBox.prototype.buildDom = function() { + var self = this; + var wrap = this.layout.needDynamic ? _.identity : makeStatic; + + return dom('div.layout_box', + kd.toggleClass('layout_leaf', wrap(this.isLeaf)), + kd.toggleClass(this.layout.leafId, wrap(this.isLeaf)), + kd.cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })), + kd.cssClass(wrap(function() { return (self.layout.fillWindow ? 'layout_fill_window' : + (self.isLastChild() ? 'layout_last_child' : null)); + })), + kd.style('flexGrow', wrap(function() { + return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : ''; + })), + kd.domData('layoutBox', this), + kd.foreach(wrap(this.childBoxes), function(layoutBox) { + return layoutBox.getDom(); + }), + kd.scope(wrap(this.leafContent), function(leafContent) { + return leafContent; + }) + ); +}; + +/** + * Moves the leaf id and content from another layoutBox, unsetting them in the source one. + */ +LayoutBox.prototype.takeLeafFrom = function(sourceLayoutBox) { + this.leafId(sourceLayoutBox.leafId.peek()); + // Note that we detach the node, so that the old box doesn't destroy its DOM. + this.leafContent(dom.detachNode(sourceLayoutBox.leafContent.peek())); + sourceLayoutBox.leafId(null); + sourceLayoutBox.leafContent(null); +}; + +LayoutBox.prototype.setChildren = function(children) { + children.forEach(function(child) { + child.parentBox(this); + }, this); + this.childBoxes.assign(children); +}; + +LayoutBox.prototype.isFirstChild = function() { + return this.parentBox() ? this.parentBox().childBoxes.peek()[0] === this : true; +}; + +LayoutBox.prototype.isLastChild = function() { + // Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and + // we want it to automatically stay correct when childBoxes array changes. + return this.parentBox() ? _.last(this.parentBox().childBoxes.all()) === this : true; +}; + +LayoutBox.prototype.isDomDetached = function() { + return !(this.dom && this.dom.parentNode); +}; + +LayoutBox.prototype.getSiblingBox = function(isAfter) { + if (!this.parentBox()) { + return null; + } + var siblings = this.parentBox().childBoxes.peek(); + var index = siblings.indexOf(this); + if (index < 0) { + return null; + } + index += (isAfter ? 1 : -1); + return (index < 0 || index >= siblings.length ? null : siblings[index]); +}; + +LayoutBox.prototype._addChild = function(childBox, isAfter, optNextSibling) { + assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set"); + var index; + if (optNextSibling) { + index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0); + } else { + index = isAfter ? this.childBoxes.peekLength : 0; + } + childBox.parentBox(this); + this.childBoxes.splice(index, 0, childBox); +}; + + +LayoutBox.prototype.addSibling = function(childBox, isAfter) { + childBox.removeFromParent(); + var parentBox = this.parentBox(); + if (parentBox) { + // Normally, we just add a sibling as requested. + parentBox._addChild(childBox, isAfter, this); + } else { + // If adding a sibling to the root node (another VBox), we need to create a new root and push + // things down two levels (HBox and VBox), and add the sibling to the lower VBox. + if (this.childBoxes.peekLength === 1) { + // Except when the root has a single child, in which case there is already a good place to + // add the new node two levels lower. And we should not create another level because the + // root is the only place that can have a single child. + var lowerBox = this.childBoxes.peek()[0]; + assert(!lowerBox.isLeaf(), 'LayoutBox.addSibling: should not have leaf as a single child'); + lowerBox._addChild(childBox, isAfter); + } else { + // Create a new root, and add the sibling two levels lower. + var vbox = LayoutBox.create(this.layout); + var hbox = LayoutBox.create(this.layout); + // We don't need removeFromParent here because this only runs when there is no parent. + vbox._addChild(hbox, false); + hbox._addChild(this, false); + hbox._addChild(childBox, isAfter); + this.layout.setRoot(vbox); + } + } + this.layout.trigger('layoutChanged'); +}; + +LayoutBox.prototype.addChild = function(childBox, isAfter) { + childBox.removeFromParent(); + if (this.isLeaf()) { + // Move the leaf data into a new child, then add the requested childBox. + var newBox = LayoutBox.create(this.layout); + newBox.takeLeafFrom(this); + this._addChild(newBox, 0); + } + this._addChild(childBox, isAfter); + this.layout.trigger('layoutChanged'); +}; + +LayoutBox.prototype.toString = function() { + return this.isDisposed() ? this.uniqueId + "[disposed]" : (this.uniqueId + + (this.isHBox() ? "H" : "V") + + (this.isLeaf() ? "(" + this.leafId() + ")" : + "[" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(",") + "]") + ); +}; + +LayoutBox.prototype._removeChildBox = function(childBox) { + //console.log("_removeChildBox %s from %s", childBox.toString(), this.toString()); + var index = this.childBoxes.peek().indexOf(childBox); + childBox.parentBox(null); + if (index >= 0) { + this.childBoxes.splice(index, 1); + this.rescaleFlexSizes(); + } + if (this.childBoxes.peekLength === 1) { + // If we now have a single child, then something needs to collapse. + var lowerBox = this.childBoxes.peek()[0]; + var parentBox = this.parentBox(); + if (lowerBox.isLeaf()) { + // Move the leaf data into ourselves, and remove the lower box. + this.takeLeafFrom(lowerBox); + lowerBox.dispose(); + } else if (parentBox) { + // Move grandchildren into our place within our parent, and collapse two levels. + // (Unless we are the root, in which case it's OK for us to have a single non-leaf child.) + index = parentBox.childBoxes.peek().indexOf(this); + assert(index >= 0, 'LayoutBox._removeChildBox: box not found in parent'); + + var grandchildBoxes = lowerBox.childBoxes.peek(); + grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); }); + parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes); + + lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength); + this.removeFromParent(); + + lowerBox.dispose(); + this.dispose(); + } + } +}; + +/** + * Helper to detach a box from its parent without disposing it. If you no longer plan to reattach + * the box, you should probably call box.dispose(). + */ +LayoutBox.prototype.removeFromParent = function() { + if (this.parentBox()) { + this.parentBox()._removeChildBox(this); + this.layout.trigger('layoutChanged'); + } +}; + +/** + * Adjust flexSize values of the children so that they add up to at least 1. + * Otherwise, Firefox will not stretch them to the full size of the container. + */ +LayoutBox.prototype.rescaleFlexSizes = function() { + // Just scale so that the smallest value is 1. + var children = this.childBoxes.peek(); + var minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); })); + if (minSize < 1) { + children.forEach(function(b) { + b.flexSize(b.flexSize() / minSize); + }); + } +}; + + +//---------------------------------------------------------------------- + +/** + * @event layoutChanged: Triggered on changes to the structure of the layout. + * @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem. + */ +function Layout(boxSpec, createLeafFunc, optFillWindow) { + this.rootBox = ko.observable(null); + this.createLeafFunc = createLeafFunc; + this._leafIdMap = null; + this.fillWindow = optFillWindow || false; + this.needDynamic = false; + this.rootElem = this.autoDispose(this.buildDom()); + + // Generates a unique id class so boxes can only be placed next to other boxes in this layout. + this.leafId = _.uniqueId('layout_leaf_'); + + this.buildLayout(boxSpec || {}); + + // Invalidate the _leafIdMap when the layout is adjusted. + this.listenTo(this, 'layoutChanged', function() { this._leafIdMap = null; }); + + this.autoDisposeCallback(function() { + if (this.rootBox()) { + this.rootBox().dispose(); + } + }); +} +exports.Layout = Layout; +dispose.makeDisposable(Layout); +_.extend(Layout.prototype, BackboneEvents); + + +/** + * Returns a LayoutBox object containing the given DOM element, or null if not found. + */ +Layout.prototype.getContainingBox = function(elem) { + return Layout.getContainingBox(elem, this.rootElem); +}; + +/** + * You can also find the nearest containing LayoutBox without having the Layout object itself by + * using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout. + */ +Layout.getContainingBox = function(elem, optContainer) { + var boxElem = dom.findAncestor(elem, optContainer, '.layout_box'); + return boxElem ? ko.utils.domData.get(boxElem, 'layoutBox') : null; +}; + +/** + * Finds and returns the leaf layout box containing the content for the given leafId. + */ +Layout.prototype.getLeafBox = function(leafId) { + return this.getLeafIdMap().get(leafId); +}; + +/** + * Returns the list of all leafIds present in this layout. + */ +Layout.prototype.getAllLeafIds = function() { + return Array.from(this.getLeafIdMap().keys()); +}; + +Layout.prototype.setRoot = function(layoutBox) { + this.rootBox(layoutBox); +}; + +Layout.prototype.buildDom = function() { + return dom('div.layout_root', + kd.domData('layoutModel', this), + kd.toggleClass('layout_fill_window', this.fillWindow), + kd.scope(this.rootBox, function(rootBox) { + return rootBox ? rootBox.getDom() : null; + }) + ); +}; + +/** + * Calls cb on each box in the layout recursively. + */ +Layout.prototype.forEachBox = function(cb, optContext) { + function iter(box) { + cb.call(optContext, box); + box.childBoxes.peek().forEach(iter); + } + iter(this.rootBox.peek()); +}; + +Layout.prototype.buildLayoutBox = function(boxSpec) { + // Note that this is hot code: it runs when rendering a layout for each record, not only for the + // layout editor. + var box = LayoutBox.create(this); + if (boxSpec.size) { + box.flexSize(boxSpec.size); + } + if (boxSpec.leaf) { + box.leafId(boxSpec.leaf); + box.leafContent(this.createLeafFunc(box.leafId())); + } else if (boxSpec.children) { + box.setChildren(boxSpec.children.map(this.buildLayoutBox, this)); + } + return box; +}; + +Layout.prototype.buildLayout = function(boxSpec, needDynamic) { + this.needDynamic = needDynamic; + var oldRootBox = this.rootBox(); + this.rootBox(this.buildLayoutBox(boxSpec)); + this.trigger('layoutChanged'); + if (oldRootBox) { + oldRootBox.dispose(); + } +}; + +Layout.prototype._getBoxSpec = function(layoutBox) { + var spec = {}; + if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) { + spec.size = layoutBox.flexSize(); + } + if (layoutBox.isLeaf()) { + spec.leaf = layoutBox.leafId(); + } else { + spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this); + } + return spec; +}; + +Layout.prototype.getLayoutSpec = function() { + return this._getBoxSpec(this.rootBox()); +}; + +/** + * Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust + * events, and rebuilt on next request. + */ +Layout.prototype.getLeafIdMap = function() { + if (!this._leafIdMap) { + this._leafIdMap = new Map(); + this.forEachBox(function(box) { + var leafId = box.leafId.peek(); + if (leafId !== null) { + this._leafIdMap.set(leafId, box); + } + }, this); + } + return this._leafIdMap; +}; diff --git a/app/client/components/LayoutEditor.css b/app/client/components/LayoutEditor.css new file mode 100644 index 00000000..a7377e55 --- /dev/null +++ b/app/client/components/LayoutEditor.css @@ -0,0 +1,97 @@ +.layout_editor_floater { + position: absolute; + overflow: hidden; + pointer-events: none; + z-index: 10; + -webkit-transform: rotate(5deg) scale(0.8); + transform: rotate(5deg) scale(0.8); + + display: -webkit-flex; + display: flex; +} + +/* Invisible div, into which we can place content that needs to be measured. */ +.layout_editor_measuring_box { + position: absolute; + left: 0px; + top: 0px; + border: none; + visibility: hidden; +} + +.layout_editor_drop_overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.1; + border-top: 0px solid #66F; + border-bottom: 0px solid #66F; + border-left: 0px solid #6F6; + border-right: 0px solid #6F6; + pointer-events: none; +} + +.layout_editor_drop_targeter { + position: absolute; + top: 0px; + left: 0px; +} +.layout_editor_drop_target { + position: absolute; + border: 2px dashed black; + z-index: 10; +} +.layout_editor_drop_target.layout_hover { + border: 2px dashed #798AF1; +} + +.layout_editor_empty_space { + background-color: rgba(0,0,0,0.1); + border-radius: 2px; + -webkit-flex: 1 1 0px; + flex: 1 1 0px; +} + +.layout_editor_resize_transition { + -webkit-transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s; + transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s; + min-height: 0px !important; + /* Important tags necessary for .layout_hbox.layout_fill_window flex boxes */ + -webkit-flex-basis: auto !important; + flex-basis: auto !important; +} + +.layout_box > .ui-resizable-handle { + opacity: 0.0; + -webkit-transition: opacity .2s; + transition: opacity .2s; +} + +.layout_box > .ui-resizable-w, +.layout_box > .ui-resizable-e { + cursor: ew-resize; + border-left: 1px dashed #a9a9a9; + margin-right: -1px; +} + +.layout_box > .ui-resizable-s { + cursor: ns-resize; + border-top: 1px dashed #a9a9a9; + margin-bottom: -1px; +} + +.layout_box > .ui-resizable-handle:hover { + opacity: 1.0; +} + +.layout_grabbable:hover { + cursor: -webkit-grab; + cursor: grab; +} + +/* TODO: Grabbing cursor does not show in Firefox */ +.layout_grabbable:active { + cursor: -webkit-grabbing; +} diff --git a/app/client/components/LayoutEditor.js b/app/client/components/LayoutEditor.js new file mode 100644 index 00000000..cd1878a1 --- /dev/null +++ b/app/client/components/LayoutEditor.js @@ -0,0 +1,738 @@ +/** + * The LayoutEditor can be attached to a Layout object to allow changing it. + * + * Issues: + * TODO: Hitting ESC while dragging should revert smoothly. We can collapse the original leaf, but + * not remove it. On Cancel, we would uncollapse it, and remove the newly-inserted targetBox. + * TODO: UNDO should work. It's OK to just rebuild the old layout without any transition. In other + * words, this may be fine to do fully outside of LayoutEditor. + * TODO: if mouseup over an active hint of the DropTargeter, it might be a better experience to + * reposition to that spot. + * + * TEST CASES THAT SHOULD BE VERIFIED AFTER ANY CHANGE. + * These refer to test/client/components/sampleLayout.js, testable at + * http://localhost:8080/testKoForm.html#topTab=4. + * 1. Drag #1 down and up its container element, pausing at borders. Elements around that border + * should smoothly float to open space for it. Dropping it should cause no jumps. + * 2. Drag #1 down to top of #6. A grey "drop target" rectangle should appear. Hovering over it + * should open space over #6. After that, dragging to bottom of #6 and back to top of #6 should + * open the space automatically without the "drop target". + * 3. Drag #3 right and left in its container, pausing at borders. Elements should again smoothly + * float to open space for it. Dropping it should cause no jumps. + * 4. Drag #4 down into #5, positioning above #5, below, to the left (splitting #5 horizontally) + * or to the right. + * 5. Drop #4 onto the leftmost "drop target" on the left side of #5. It should end up as 1/3 of + * the width of the entire layout, spanning the full height above #6. Drop it back to its place + * between #3 and #9. + * 6. Resizing: every vertical line should allow dragging it left or right to resize. The "resize" + * mouse pointer should appear over a few pixels to the left and right of the border, it should + * not be a difficult area to target. (This gets messed up if overflow:hidden is set on the box + * elements.) + * 7. Drag box 3 to trash; hovering should make it disappear from Layout, mousing back should + * bring it back. Mouse-up over the trash icon should leave it out of the layout. + * 8. Drag boxes 3, 9, 10, 2, 7, 1 (8 should stretch vertically), 5 to trash. They should + * disappear with other elements shrinking or expanding to close the gap. + * 9. Adding a new element: Drag "+ Add New" box to between 1 and 2. A "drop target" should + * appear, allowing you to insert it. Same for adding between 3 and 4. Should be no jumps. + * 10. Drag new element to above #3: three possible drop targets should appear. Hover over each in + * turn, starting from the bottommost part, and make sure it gets inserted in the right level. + */ + + +var _ = require('underscore'); +var ko = require('knockout'); +var assert = require('assert'); +var Promise = require('bluebird'); +var BackboneEvents = require('backbone').Events; + +var dispose = require('app/client/lib/dispose'); +var {Delay} = require('app/client/lib/Delay'); +var dom = require('app/client/lib/dom'); +var kd = require('app/client/lib/koDom'); +var Layout = require('./Layout'); + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +var G = require('../lib/browserGlobals').get('window', 'document', '$'); + +//---------------------------------------------------------------------- + +/** + * The Floater class represents a floating version of the element being dragged around. Its size + * corresponds to the box being dragged. It lets the user see what's being repositioned. + */ +function Floater(fillWindow) { + this.leafId = ko.observable(null); + this.leafContent = ko.observable(null); + this.fillWindow = fillWindow || false; + + this.floaterElem = this.autoDispose(dom('div.layout_editor_floater', + kd.show(this.leafContent), + kd.scope(this.leafContent, function(leafContent) { + return leafContent; + }) + )); + G.document.body.appendChild(this.floaterElem); + + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + this.lastMouseEvent = null; +} +dispose.makeDisposable(Floater); + +Floater.prototype.onInitialMouseMove = function(mouseEvent, sourceBox) { + var rect = sourceBox.dom.getBoundingClientRect(); + this.floaterElem.style.width = rect.width + 'px'; + this.floaterElem.style.height = rect.height + 'px'; + this.mouseOffsetX = 0.2 * rect.width; + this.mouseOffsetY = 0.1 * rect.height; + this.onMouseMove(mouseEvent); + + this.leafId(sourceBox.leafId()); + this.leafContent(sourceBox.leafContent()); + // We use a dummy non-null leafId here, to ensure that sourceBox remains considered a leaf. + sourceBox.leafId('empty'); + sourceBox.leafContent(dom('div.layout_editor_empty_space', + kd.style('margin', (rect.height * 0.02) + 'px'), + kd.style('min-height', (rect.height * 0.96) + 'px') + )); +}; + +Floater.prototype.onMouseUp = function() { + this.lastMouseEvent = null; +}; + +Floater.prototype.onMouseMove = function(mouseEvent) { + this.lastMouseEvent = mouseEvent; + this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px'; + this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px'; +}; + +//---------------------------------------------------------------------- + +/** + * When the user hovers near the edge of a box, we call the direction the "affinity", and it + * indicates where an insertion is to happen. Affinities are represented by numbers 0 - 3. The + * functions below distinguish top-down vs left-right, and top/left vs down/right. + */ +//var AFFINITY_NAMES = { 0: 'TOP', 1: 'DOWN', 2: 'LEFT', 3: 'RIGHT' }; +function isAffinityUpDown(affinity) { return (affinity >> 1) === 0; } +function isAffinityAfter(affinity) { return (affinity & 1) === 1; } + +//---------------------------------------------------------------------- + +/** + * DropOverlay is a rectangular indicator that's displayed over a leaf box under the mouse + * pointer, and shows regions of affinity towards one of the borders. It also computes which + * region the user is targeting, and returns an affinity value. + */ +function DropOverlay() { + this.overlayElem = this.autoDispose(dom('div.layout_editor_drop_overlay')); + this.overlayRect = null; + this.hBorder = null; + this.vBorder = null; +} +dispose.makeDisposable(DropOverlay); + +/** + * Hides the overlay box by detaching it from the current element, if any. + */ +DropOverlay.prototype.detach = function() { + if (this.overlayElem.parentNode) { + this.overlayElem.parentNode.removeChild(this.overlayElem); + } +}; + +function getFrac(distance, max) { + return distance < max ? distance / max : Infinity; +} + +/** + * Shows the overlay box over the given element. + */ +DropOverlay.prototype.attach = function(targetElem) { + var rect = this.overlayRect = targetElem.getBoundingClientRect(); + /* + // If uncommented, this will show areas of affinity when hovering over a box. This is helpful in + // debugging, and may be helpful to users too, but makes the interface feel more cluttered. + if (this.overlayElem.parentNode !== targetElem) { + // This also automatically removes it from the old parent, if any. + targetElem.appendChild(this.overlayElem); + } + */ + + // Areas of affinity are essentially fat borders, proportional to width and height. In addition, + // to avoid overly disproportionate regions, we use twice the smaller dimension to limit the + // larger dimension. + this.hBorder = Math.floor(Math.min(rect.height, rect.width * 2) / 3); + this.vBorder = Math.floor(Math.min(rect.width, rect.height * 2) / 3); + var s = this.overlayElem.style; + s.borderTopWidth = s.borderBottomWidth = this.hBorder + 'px'; + s.borderLeftWidth = s.borderRightWidth = this.vBorder + 'px'; +}; + +/** + * If the mouse is over a region of affinity, returns the affinity as an 0-3 integer (see + * AFFINITY_NAMES above). Otherwise, returns -1. + */ +DropOverlay.prototype.getAffinity = function(mouseEvent) { + var rect = this.overlayRect; + var x = mouseEvent.clientX - rect.left, + y = mouseEvent.clientY - rect.top, + top = getFrac(y, this.hBorder), + down = getFrac(rect.height - y, this.hBorder), + left = getFrac(x, this.vBorder), + right = getFrac(rect.width - x, this.vBorder), + minValue = Math.min(top, down, left, right); + + return (minValue === Infinity ? -1 : [top, down, left, right].indexOf(minValue)); +}; + +//---------------------------------------------------------------------- + +/** + * DropTargeter displays a set of rectangles, each of which represents a particular allowed + * insertion point for the element being dragged. It only shows the insertion points at the edge + * of a particular layoutBox as indicated by DropOverlay. + */ +function DropTargeter(rootElem) { + this.rootElem = rootElem; + this.targetsDom = null; + this.currentBox = null; + this.currentAffinity = null; + this.delayedInsertion = Delay.create(); + this.activeTarget = null; + this.autoDisposeCallback(this.removeTargetHints); +} +dispose.makeDisposable(DropTargeter); +_.extend(DropTargeter.prototype, BackboneEvents); + +DropTargeter.prototype.removeTargetHints = function() { + this.activeTarget = null; + this.delayedInsertion.cancel(); + if (this.targetsDom) { + ko.removeNode(this.targetsDom); + this.targetsDom = null; + } + this.currentBox = null; + this.currentAffinity = null; +}; + +DropTargeter.prototype.updateTargetHints = function(layoutBox, affinity, overlay, prevTargetBox) { + // Nothing to update. + if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) { + return; + } + this.removeTargetHints(); + if (affinity === -1) { + return; + } + this.currentBox = layoutBox; + this.currentAffinity = affinity; + + var upDown = isAffinityUpDown(affinity); + var isAfter = isAffinityAfter(affinity); + + var targetParts = []; + // Allow dragging a leaf into another leaf as a child, splitting the latter into two. + // But don't allow dragging a leaf box into itself, that makes no sense. + if (upDown === layoutBox.isVBox() && layoutBox !== prevTargetBox) { + targetParts.push({ box: layoutBox, isChild: true, isAfter: isAfter }); + } + while (layoutBox) { + if (upDown === layoutBox.isHBox()) { + var children = layoutBox.childBoxes.peek(); + // If one of two children is prevTargetBox, replace the last target hint since it + // will be redundant once prevTargetBox is removed. + if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) { + targetParts.splice(targetParts.length - 1, 1, + { box: layoutBox, isChild: false, isAfter: isAfter }); + } + // If there is only one child (which may happen for the root box), the target hint + // is redundant. + else if (prevTargetBox !== layoutBox && prevTargetBox !== layoutBox.getSiblingBox(isAfter) && + children.length !== 1) { + targetParts.push({ box: layoutBox, isChild: false, isAfter: isAfter }); + } + if (isAfter && !layoutBox.isLastChild()) { break; } + if (!isAfter && !layoutBox.isFirstChild()) { break; } + } + layoutBox = layoutBox.parentBox(); + } + if (targetParts.length === 0) { + return; + } + + // Render the hint parts. + if (!isAfter) { + targetParts.reverse(); + } + + // The same code works for both horizontal and vertical situation. For ease of thinking about + // it, we pretend below that we are dealing with an up-down situation (drop hints are horizontal + // wide boxes stacked vertically), and use properties that are named using the up-down + // situation, but whose values might reflect a left-right situation. + var pTop = upDown ? 'top' : 'left', + pHeight = upDown ? 'height' : 'width', + pLeft = upDown ? 'left' : 'top', + pWidth = upDown ? 'width' : 'height', + totalHeight = upDown ? overlay.hBorder : overlay.vBorder, + singleHeight = Math.floor(totalHeight / targetParts.length); + + // Adjust to account for the rounding-down above. + totalHeight = singleHeight * targetParts.length; + + var outerRect = this.rootElem.getBoundingClientRect(); + var innerRect = this.currentBox.dom.getBoundingClientRect(); + + var self = this; + this.targetsDom = dom('div.layout_editor_drop_targeter', + kd.style(pTop, + (innerRect[pTop] - outerRect[pTop] + + (isAfter ? innerRect[pHeight] - totalHeight : 0)) + 'px' + ), + targetParts.map(function(part, index) { + var rect = part.box.dom.getBoundingClientRect(); + return dom('div.layout_editor_drop_target', function(elem) { + elem.style[pHeight] = (singleHeight + 1) + 'px'; // 1px of overlap for better looks + elem.style[pWidth] = rect[pWidth] + 'px'; + elem.style[pLeft] = (rect[pLeft] - outerRect[pLeft]) + 'px'; + elem.style[pTop] = (singleHeight * index) + 'px'; + }, + dom.on('mouseenter', function() { + this.classList.add("layout_hover"); + self.activeTarget = part; + var padDir = upDown ? (isAfter ? 'Bottom' : 'Top') : (isAfter ? 'Right' : 'Left'); + var padding = 'padding' + padDir; + part.box.dom.style.transition = 'padding .3s'; + part.box.dom.style[padding] = '20px'; + }), + dom.on('mouseleave', function() { + this.classList.remove("layout_hover"); + self.activeTarget = null; + part.box.dom.style.padding = '0'; + }), + dom.on('transitionend', this.triggerInsertion.bind(this, part)) + ); + }, this) + ); + this.rootElem.appendChild(this.targetsDom); +}; + +DropTargeter.prototype.triggerInsertion = function(part) { + this.removeTargetHints(); + this.trigger('insertBox', function(box) { + if (part.isChild) { + part.box.addChild(box, part.isAfter); + } else { + part.box.addSibling(box, part.isAfter); + } + }); +}; + +DropTargeter.prototype.accelerateInsertion = function() { + if (this.activeTarget) { + this.activeTarget.box.dom.style.transition = ''; + this.activeTarget.box.dom.style.padding = '0'; + this.triggerInsertion(this.activeTarget); + } +}; + +//---------------------------------------------------------------------- + +/** + * When a LayoutEditor is created for a given Layout object, it makes it possible to drag + * LayoutBoxes to change the layout. + * + * When a user drags a box, its content migrates temporarily to the Floater element, which moves + * with the mouse cursor. As the user drags, the space for the element will open up here or there, + * by adding an appropriate empty targetBox. DropOverlay and DropTargeter together decide the + * insertion point for the drag operations. + * + * NOTES: + * There is some awkwardness in sizing: in a vertically laid out box, the last box takes up all + * available space, so moving it away does not show a transition (the box transitions to empty in + * theory, but it still takes all the same available space). + */ +function LayoutEditor(layout) { + this.layout = layout; + this.rootElem = layout.rootElem; + + this.layout.buildLayout(this.layout.getLayoutSpec(), true); + this.floater = this.autoDispose(Floater.create(this.layout.fillWindow)); + this.dropOverlay = this.autoDispose(DropOverlay.create()); + this.dropTargeter = this.autoDispose(DropTargeter.create(this.rootElem)); + this.listenTo(this.dropTargeter, 'insertBox', this.onInsertBox); + + // This is a place to put LayoutBoxes that should NOT be shown, but SHOULD be possible to + // measure. It's used when a new box is being moved into the editor. + this.measuringBox = this.autoDispose(dom('div.layout_editor_measuring_box')); + this.rootElem.appendChild(this.measuringBox); + + // For better experience, we prevent new repositions while a transition is active, and we + // require some work (leaving and re-entering affinity area) after a previous transition ends. + this.transitionPromise = Promise.resolve(); + this.trashDelay = Delay.create(); + + // TODO: We don't use originalBox at the moment, but may want to, specifically to collapse it + // without removing, and restore if the user hits "Escape". + // This is the box the user clicked, to move its content elsewhere. + this.originalBox = null; + + // The new box into which the content is to be inserted. During a move operation, it starts out + // with this.originalBox. + this.targetBox = null; + + // Make all LayoutBoxes resizable. Update whenever the layout changes. + this.layout.forEachBox(this.makeResizable, this); + this.listenTo(this.layout, 'layoutChanged', function() { + this.layout.forEachBox(this.makeResizable, this); + }); + + var self = this; + this.boundMouseDown = function(ev) { return self.handleMouseDown(ev, this); }; + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundMouseUp = this.handleMouseUp.bind(this); + G.$(this.rootElem).on('mousedown', '.layout_leaf', this.boundMouseDown); + + this.initialMouseDown = false; + + this.lastTriggered = 'stop'; + + this.autoDisposeCallback(function() { + G.$(G.window).off('mouseup', this.boundMouseUp); + G.$(G.window).off('mousemove', this.boundMouseMove); + G.$(this.rootElem).off('mousedown', this.boundMouseDown); + if (!this.layout.isDisposed()) { + this.layout.buildLayout(this.layout.getLayoutSpec(), false); + this.layout.forEachBox(this.unmakeResizable, this); + } + }); +} +exports.LayoutEditor = LayoutEditor; + +dispose.makeDisposable(LayoutEditor); +_.extend(LayoutEditor.prototype, BackboneEvents); + +LayoutEditor.prototype.triggerUserEditStart = function() { + assert(this.lastTriggered === 'stop', "UserEditStart triggered twice in succession"); + this.lastTriggered = 'start'; + // This attribute allows browser tests to tell when an edit is in progress. + this.rootElem.setAttribute('data-useredit', 'start'); + this.layout.trigger('layoutUserEditStart'); +}; + +LayoutEditor.prototype.triggerUserEditStop = function() { + assert(this.lastTriggered === 'start', "UserEditStop triggered twice in succession"); + this.lastTriggered = 'stop'; + this.layout.trigger('layoutUserEditStop'); + // This attribute allows browser tests to tell when an edit is finished. + this.rootElem.setAttribute('data-useredit', 'stop'); +}; + +LayoutEditor.prototype.makeResizable = function(box) { + // Do not add resizable if: + // Box already resizable, box is not vertically resizable, box is last in it`s group. + if (G.$(box.dom).resizable('instance') || (box.isHBox() && !this.layout.fillWindow) || + box.isLastChild()) { + return; + } + var helperObj = { box: box }; + var isWidth = box.isVBox(); + G.$(box.dom).resizable({ + handles: isWidth ? 'e' : 's', + start: this.onResizeStart.bind(this, helperObj, isWidth), + resize: this.onResizeMove.bind(this, helperObj, isWidth), + stop: this.triggerUserEditStop.bind(this) + }); +}; + +LayoutEditor.prototype.unmakeResizable = function(box) { + if (G.$(box.dom).resizable("instance")) { + // Resizable widget is set for this box. + G.$(box.dom).resizable('destroy'); + } +}; + +LayoutEditor.prototype.onResizeStart = function(helperObj, isWidth, event, ui) { + this.triggerUserEditStart(); + var size = isWidth ? ui.originalSize.width : ui.originalSize.height; + helperObj.scalePerFlexUnit = size / (helperObj.box.flexSize() || 1); + var allSiblings = helperObj.box.parentBox().childBoxes.peek(); + var index = allSiblings.indexOf(helperObj.box); + helperObj.nextSiblings = allSiblings.slice(index + 1); + helperObj.origNextSizes = helperObj.nextSiblings.map(function(b) { return b.flexSize(); }); + helperObj.origSize = helperObj.box.flexSize(); + function adder(sum, box) { return sum + box.flexSize.peek(); } + helperObj.sumPrev = allSiblings.slice(0, index).reduce(adder, 0); + helperObj.sumAll = allSiblings.reduce(adder, 0); + helperObj.sumNext = helperObj.sumAll - helperObj.sumPrev; +}; + +// We'll snap to 1/NumSteps of total size. The choice of 60 allows many evenly-sized layouts. +var NumSteps = 60; + +function round(value, multipleOf) { + return Math.round(value / multipleOf) * multipleOf; +} + +function snap(flexSize, sumPrev, sumAll) { + var endEdge = round(sumPrev + flexSize, sumAll / NumSteps); + return Math.min(endEdge, sumAll) - sumPrev; +} + +LayoutEditor.prototype.onResizeMove = function(helperObj, isWidth, event, ui) { + var sizePx = isWidth ? ui.size.width : ui.size.height; + var newSize = sizePx / helperObj.scalePerFlexUnit; + + // We need some amount of snapping to make it easier to align boxes. The way we'll do it is to + // adjust flexSize of the box being resized and all following boxes so that boundaries end up at + // multiples of fullSize / NumSteps. + + newSize = snap(newSize, helperObj.sumPrev, helperObj.sumAll); + var siblingsFactor = (helperObj.sumNext - newSize) / (helperObj.sumNext - helperObj.origSize); + var sumPrev = helperObj.sumPrev + newSize; + var newSizes = []; + helperObj.origNextSizes.forEach(function(size) { + var s = snap(size * siblingsFactor, sumPrev, helperObj.sumAll); + sumPrev += s; + newSizes.push(s); + }); + + if (newSize <= 0 || newSizes.some(function(size) { return size <= 0; })) { + return; // This isn't an acceptable position. + } + if (newSize !== helperObj.box.flexSize.peek()) { + helperObj.box.flexSize(newSize); + helperObj.nextSiblings.forEach(function(b, i) { + b.flexSize(newSizes[i]); + }); + this.layout.trigger('layoutResized'); + } +}; + +LayoutEditor.prototype.handleMouseDown = function(event, elem) { + if (event.button !== 0 || event.target.classList.contains('ui-resizable-handle')) { + return; + } + if (event.target.classList.contains('layout_grabbable')) { + this.initialMouseDown = true; + this.originalBox = ko.utils.domData.get(elem, 'layoutBox'); + assert(this.originalBox, "MouseDown on element without an associated layoutBox"); + G.$(G.window).on('mousemove', this.boundMouseMove); + G.$(G.window).on('mouseup', this.boundMouseUp); + return false; + } +}; + +LayoutEditor.prototype.dragInNewBox = function(event, leafId) { + var box = this.layout.buildLayoutBox({leaf: leafId}); + + // Place this box into a measuring div. + this.measuringBox.appendChild(box.getDom()); + + this.handleMouseDown(event, box.dom); +}; + +LayoutEditor.prototype.startDragBox = function(event, box) { + this.triggerUserEditStart(); + this.targetBox = box; + this.floater.onInitialMouseMove(event, box); +}; + +LayoutEditor.prototype.handleMouseUp = function(event) { + G.$(G.window).off('mousemove', this.boundMouseMove); + G.$(G.window).off('mouseup', this.boundMouseUp); + + if (this.initialMouseDown) { + this.initialMouseDown = false; + return; + } + + this.targetBox.takeLeafFrom(this.floater); + if (this.dropTargeter.activeTarget) { + this.dropTargeter.accelerateInsertion(); + } else { + resizeLayoutBox(this.targetBox, 'reset'); + } + + this.dropTargeter.removeTargetHints(); + this.dropOverlay.detach(); + + this.transitionPromise.bind(this).finally(function() { + this.floater.onMouseUp(); + resizeLayoutBox(this.targetBox, 'reset'); + this.targetBox = this.originalBox = null; + dispose.emptyNode(this.measuringBox); + this.triggerUserEditStop(); + }); +}; + +LayoutEditor.prototype.removeContainingBox = function(elem) { + var box = this.layout.getContainingBox(elem); + if (box && !box.isDomDetached()) { + this.triggerUserEditStart(); + this.targetBox = box; + var rect = box.dom.getBoundingClientRect(); + box.leafId('empty'); + box.leafContent(dom('div.layout_editor_empty_space', + kd.style('min-height', rect.height + 'px') + )); + this.onInsertBox(_.noop, rect); + this.triggerUserEditStop(); + } +}; + +LayoutEditor.prototype.handleMouseMove = function(event) { + // Make sure the grabbed box still exists + if (this.originalBox.isDisposed()) { + return; + } + + if (this.initialMouseDown) { + this.initialMouseDown = false; + this.startDragBox(event, this.originalBox); + } + this.floater.onMouseMove(event); + + if (this.transitionPromise.isPending()) { + // Don't attempt to do any repositioning while another reposition is happening. + return; + } + + // Handle dragging to trash. + if (dom.findAncestor(event.target, null, '.layout_trash')) { + var isTrashed = this.targetBox && this.targetBox.isDomDetached(); + if (!this.trashDelay.isPending() && !isTrashed) { + // To "trash" a box, we call onInsertBox with noop for the inserter function. The new box + // will still be created, just not attached to anything. + this.trashDelay.schedule(100, this.onInsertBox, this, _.noop); + } + return; + } + this.trashDelay.cancel(); + + // See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged + // element. If so, we are dealing with repositioning. + var elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId); + if (elem) { + var hoverBox = ko.utils.domData.get(elem, 'layoutBox'); + this.dropOverlay.attach(elem); + var affinity = this.dropOverlay.getAffinity(event); + this.dropTargeter.updateTargetHints(hoverBox, affinity, this.dropOverlay, this.targetBox); + } else if (!dom.findAncestor(event.target, this.rootElem, '.layout_editor_drop_target')) { + this.dropTargeter.removeTargetHints(); + } +}; + + +/** + * Resizes the given LayoutBox to transition it when it's supposed to expand or collapse. It only + * affects the height for HBoxes, and only the width for VBoxes. For rows, we use an explicit + * height. For columns we rely on 'flex-grow' property. + * A rectangle object: set the relevant style according to the values there. + * 'reset': unset the relevant style, to revert to the values associated with CSS classes. + * 'collapse': collapse to empty size. + * 'current': set and explicit value for the relevant style, which is needed for transitions. + */ +function resizeLayoutBox(layoutBox, sizeRect) { + var reset = (sizeRect === 'reset'); + var collapse = (sizeRect === 'collapse'); + if (sizeRect === 'current') { + sizeRect = layoutBox.dom.getBoundingClientRect(); + } + if (layoutBox.isHBox()) { + layoutBox.dom.style.height = (reset ? '' : (collapse ? '0px' : sizeRect.height + 'px')); + } else { + layoutBox.dom.style.width = (reset ? '' : (collapse ? '0px' : sizeRect.width + 'px')); + } + layoutBox.dom.style.opacity = collapse ? '0.0' : '1.0'; +} + +function rectDesc(rect) { + return (typeof rect === 'string') ? rect : + Math.floor(rect.width) + "x" + Math.floor(rect.height); +} + +/** + * Resizes the given LayoutBox smoothly from starting to ending position, where startRect and + * endRect are one of the values documented in 'resizeLayoutBox'. + */ +function resizeLayoutBoxSmoothly(layoutBox, startRect, endRect) { + if (layoutBox.isDomDetached()) { + return Promise.resolve(); + } + var prevFlexGrow = layoutBox.dom.style.flexGrow; + layoutBox.dom.style.flexGrow = 0; + resizeLayoutBox(layoutBox, startRect); + + // Force the layout engine to compute the current state of the layoutBox.dom element before + // applying the transition. This follows the recommendation here, and seems to work: + // https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/ + _.pick(G.window.getComputedStyle(layoutBox.dom), 'height', 'width'); + + // Start the transition. + layoutBox.dom.classList.add('layout_editor_resize_transition'); + return new Promise(function(resolve, reject) { + dom.once(layoutBox.dom, 'transitionend', function() { resolve(); }); + resizeLayoutBox(layoutBox, endRect); + }) + .timeout(600) // Transitions are only 400ms long, so complain if nothing happened for longer. + .catch(Promise.TimeoutError, function() { + console.error("LayoutEditor.resizeLayoutBoxSmoothly %s %s->%s: transition didn't run", + layoutBox, rectDesc(startRect), rectDesc(endRect)); + // We keep going. It should look like something's wrong and jumpy, but it should still be + // usable and not cause errors elsewhere. + }) + .finally(function() { + layoutBox.dom.classList.remove('layout_editor_resize_transition'); + layoutBox.dom.style.flexGrow = prevFlexGrow; + }); +} + +LayoutEditor.prototype.onInsertBox = function(inserterFunc, optRect) { + // Create a new LayoutBox, and insert it using inserterFunc. + // Shrink prevTargetBox to 0. Create a new target box, initially shrunk, and grow it. + var prevTargetBox = this.targetBox; + + this.targetBox = Layout.LayoutBox.create(this.layout); + this.targetBox.takeLeafFrom(prevTargetBox); + this.targetBox.flexSize(prevTargetBox.flexSize()); + + // Sizing boxes vertically requires extra care that the sum of values doesn't change. + this.targetBox.getDom(); // Make sure its dom is created. + + //console.log("onInsertBox %s -> %s", prevTargetBox, this.targetBox); + var transitionPromiseResolve; + this.transitionPromise = new Promise(function(resolve, reject) { + transitionPromiseResolve = resolve; + }); + + inserterFunc(this.targetBox); + + var prevRect = prevTargetBox.dom.getBoundingClientRect(); + + // Set previous box size to 0 for accurate measurement of new target box + var prevFlexGrow = prevTargetBox.dom.style.flexGrow; + prevTargetBox.dom.style.flexGrow = 0; + + var targetRect = this.targetBox.dom.getBoundingClientRect(); + + prevTargetBox.dom.style.flexGrow = prevFlexGrow; + + return Promise.all([ + resizeLayoutBoxSmoothly(prevTargetBox, prevRect, 'collapse'), + resizeLayoutBoxSmoothly(this.targetBox, 'collapse', targetRect), + ]) + .bind(this).then(function() { + prevTargetBox.dispose(); + if (this.targetBox) { + resizeLayoutBox(this.targetBox, 'reset'); + this.dropOverlay.attach(this.targetBox.dom); + } + + transitionPromiseResolve(); + this.layout.trigger('layoutResized'); + }); +}; diff --git a/app/client/components/LayoutPreview.css b/app/client/components/LayoutPreview.css new file mode 100644 index 00000000..4a68a18d --- /dev/null +++ b/app/client/components/LayoutPreview.css @@ -0,0 +1,7 @@ +.layout_preview_leaf { + position: relative; + flex: 1 1 0px; + background-color: black; + margin: 1px 0 0 1px; + border-radius: 1px; +} diff --git a/app/client/components/LayoutPreview.js b/app/client/components/LayoutPreview.js new file mode 100644 index 00000000..765060d8 --- /dev/null +++ b/app/client/components/LayoutPreview.js @@ -0,0 +1,40 @@ +var ko = require('knockout'); +var dom = require('../lib/dom'); +var dispose = require('../lib/dispose'); +var Layout = require('./Layout'); + +/** + * LayoutPreview - Represents a preview for a single layout. Builds an icon that takes + * the size of its container showing a version of the layout made from solid blocks. + * An optional map between leafId and hex color strings may be used to color the blocks. + * The map may be an observable, but it is only consulted on changes to layoutSpecObj. + */ +function LayoutPreview(layoutSpecObj, optColorMap) { + var self = this; + this.layoutSpecObj = layoutSpecObj; + this.colorMap = optColorMap || {}; + + this.layout = this.autoDispose( + Layout.Layout.create(this.layoutSpecObj(), + function(leafId) { + var content = dom('div.layout_preview_leaf'); + var colorMap = ko.unwrap(self.colorMap); + content.style.backgroundColor = colorMap[leafId] || "#000"; + return content; + }, true) + ); + + // When the layoutSpec changes, rebuild. + this.autoDispose(this.layoutSpecObj.subscribe(function(spec) { + this.layout.buildLayout(this.layoutSpecObj(), true); + }, this)); + +} +dispose.makeDisposable(LayoutPreview); + + +LayoutPreview.prototype.buildDom = function() { + return this.layout.rootElem; +}; + +module.exports = LayoutPreview; diff --git a/app/client/components/LinkingState.js b/app/client/components/LinkingState.js new file mode 100644 index 00000000..f62681a6 --- /dev/null +++ b/app/client/components/LinkingState.js @@ -0,0 +1,122 @@ +const _ = require('underscore'); +const ko = require('knockout'); +const dispose = require('../lib/dispose'); +const gutil = require('app/common/gutil'); + +/** + * Returns if the first table is a summary of the second. If both are summary tables, returns true + * if the second table is a more detailed summary, i.e. has additional group-by columns. + * @param {MetaRowModel} summary: RowModel for the table to check for being the summary table. + * @param {MetaRowModel} detail: RowModel for the table to check for being the detailed version. + * @returns {Boolean} Whether the first argument is a summarized version of the second. + */ +function isSummaryOf(summary, detail) { + let summarySource = summary.summarySourceTable(); + if (summarySource === detail.getRowId()) { return true; } + let detailSource = detail.summarySourceTable(); + return (summarySource && + detailSource === summarySource && + summary.getRowId() !== detail.getRowId() && + gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs())); +} + + +/** + * Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling. + * Exposes .filterColValues, which is either null or a computed evaluating to a filtering object; + * and .cursorPos, which is either null or a computed that evaluates to a cursor position. + * LinkingState must be created with a valid srcSection and tgtSection. + * + * There are several modes of linking: + * (1) If tgtColId is set, tgtSection will be filtered to show rows whose values of target column + * are equal to the value of source column in srcSection at the cursor. With byAllShown set, all + * values in srcSection are used (rather than only the value in the cursor). + * (2) If srcSection is a summary of tgtSection, then tgtSection is filtered to show only those + * rows that match the row at the cursor of srcSection. + * (3) If tgtColId is null, tgtSection is scrolled to the rowId determined by the value of the + * source column at the cursor in srcSection. + * + * @param gristDoc: GristDoc instance, for getting the relevant TableData objects. + * @param srcSection: RowModel for the section that drives the target section. + * @param srcColId: Name of the column that drives the target section, or null to use rowId. + * @param tgtSection: RowModel for the section that's being driven. + * @param tgtColId: Name of the reference column to auto-filter by, or null to auto-scroll. + * @param byAllShown: For auto-filter, filter by all values in srcSection rather than only the + * value at the cursor. The user can use column filters on srcSection to control what's shown + * in the linked tgtSection. + */ +function LinkingState(gristDoc, srcSection, srcColId, tgtSection, tgtColId, byAllShown) { + this._srcSection = srcSection; + + let srcTableData = gristDoc.getTableModel(srcSection.table().tableId()).tableData; + + // Function from srcRowId (i.e. srcSection.activeRowId()) to the source value. It is used for + // filtering or for cursor positioning, depending on the setting of tgtCol. + let srcValueFunc = srcColId ? srcTableData.getRowPropFunc(srcColId) : _.identity; + + // If linking affects target section's cursor, this will be a computed for the cursor rowId. + this.cursorPos = null; + + // If linking affects filtering, this is a computed for the current filtering state, as a + // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId(). Otherwise, null. + this.filterColValues = null; + + // A computed that evaluates to a filter function to use, or null if not filtering. If + // filtering, depends on srcSection.activeRowId(). + if (tgtColId) { + if (byAllShown) { + // Include all values present in srcSection. + this.filterColValues = this.autoDispose(ko.computed(() => { + const srcValues = new Set(); + const viewInstance = srcSection.viewInstance(); + if (viewInstance) { + for (const srcRowId of viewInstance.sortedRows.getKoArray().all()) { + if (srcRowId !== 'new') { + srcValues.add(srcValueFunc(srcRowId)); + } + } + } + return {[tgtColId]: Array.from(srcValues)}; + })); + } else { + this.filterColValues = this.autoDispose(ko.computed(() => { + const srcRowId = srcSection.activeRowId(); + const srcValue = srcValueFunc(srcRowId); + return {[tgtColId]: [srcValue]}; + })); + } + } else if (isSummaryOf(srcSection.table(), tgtSection.table())) { + // We filter summary tables when a summary section is linked to a more detailed one without + // specifying src or target column. The filtering is on the shared group-by column (i.e. all + // those in the srcSection). + // TODO: This approach doesn't help cursor-linking (the other direction). If we have the + // inverse of summary-table's 'group' column, we could implement both, and more efficiently. + this.filterColValues = this.autoDispose(ko.computed(() => { + const srcRowId = srcSection.activeRowId(); + const filter = {}; + for (const c of srcSection.table().columns().all()) { + if (c.summarySourceCol()) { + const colId = c.summarySource().colId(); + const srcValue = srcTableData.getValue(srcRowId, colId); + filter[colId] = [srcValue]; + } + } + return filter; + })); + } else if (isSummaryOf(tgtSection.table(), srcSection.table())) { + // TODO: We should move the cursor, but don't currently it for summaries. For that, we need a + // column or map representing the inverse of summary table's "group" column. + } else { + this.cursorPos = this.autoDispose(ko.computed(() => srcValueFunc(srcSection.activeRowId()))); + } +} +dispose.makeDisposable(LinkingState); + +/** + * Returns a boolean indicating whether editing should be disabled in the destination section. + */ +LinkingState.prototype.disableEditing = function() { + return this.filterColValues && this._srcSection.activeRowId() === 'new'; +}; + +module.exports = LinkingState; diff --git a/app/client/components/Login.css b/app/client/components/Login.css new file mode 100644 index 00000000..faf8fcaa --- /dev/null +++ b/app/client/components/Login.css @@ -0,0 +1,81 @@ +.login-services { + margin: 0 15%; +} + +.login-btns > .kf_elem { + flex: 1 1 100%; +} + +.login-spacer { + height: 10px; +} + +.login-divider { + height: 0; + width: 100%; + margin-top: 20px; + border-bottom: 1px solid #ccc; + margin-bottom: 20px; +} + +.login-divider-text { + text-align: center; + display: inline-block; + position: absolute; + font-size: 1.2rem; + margin: -0.7rem auto 0 auto; + left: 0; + right: 0; + background-color: white; + width: 50px; + color: #aaa; +} + +.login-error-notify { + background-color: #fdd; + padding: 10px; + margin: 10px 20px; + border: 1px solid #daa; +} + +.login-success-notify { + background-color: #dfd; + padding: 10px; + margin: 10px 20px; + border: 1px solid #ada; +} + +.login-send-code-box { + text-align: center; + margin-top: 10px; +} + +.login-send-code { + display: inline-block; + color: #337ab7; + cursor: pointer; +} + +.profile-row { + font-size: 1.2rem; +} + +.edit-profile.btn { + background: none; + box-shadow: none; +} + +.edit-profile.btn:hover { + color: #aaa; +} + +.edit-profile.btn:active { + outline: none; +} + +.edit-profile-form { + margin: 10px; + padding: 10px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} diff --git a/app/client/components/Login.js b/app/client/components/Login.js new file mode 100644 index 00000000..4134f5f5 --- /dev/null +++ b/app/client/components/Login.js @@ -0,0 +1,129 @@ +/* global window */ + + +// External dependencies +const Promise = require('bluebird'); +const ko = require('knockout'); + +// Grist client libs +const dispose = require('../lib/dispose'); +const ProfileForm = require('./ProfileForm'); + +/** + * Login - Handles dom and settings for the login box. + * @param {app} app - The app instance. + */ +function Login(app) { + this.app = app; + this.comm = this.app.comm; + + // When logged in, an object containing user profile properties. + this._profile = ko.observable(); + this.isLoggedIn = ko.observable(false); + this.emailObs = this.autoDispose(ko.computed(() => ((this._profile() && this._profile().email) || ''))); + this.nameObs = this.autoDispose(ko.computed(() => ((this._profile() && this._profile().name) || ''))); + this.buttonText = this.autoDispose(ko.computed(() => + this.isLoggedIn() ? this.emailObs() : 'Log in')); + + // Instantialized with createLoginForm() and createProfileForm() + this.profileForm = null; +} +dispose.makeDisposable(Login); + +/** + * Returns the current profile object. + */ +Login.prototype.getProfile = function() { + return this._profile(); +}; + +/** + * Opens the Cognito login form in a new browser window to allow the user to log in. + * The login tokens are sent back to the server to which this client belongs. + */ +Login.prototype.login = function() { + if (window.isRunningUnderElectron) { + // Under electron, we open the login URL (it opens in a user's default browser). + // With null for redirectUrl, it will close automatically when login completes. + return this.comm.getLoginUrl(null) + .then((loginUrl) => window.open(loginUrl)); + } else { + // In hosted / dev version, we redirect to the login URL, and it will redirect back to the + // starting URL when login completes. + return this.comm.getLoginUrl(window.location.href) + .then((loginUrl) => { window.location.href = loginUrl; }); + } +}; + +/** + * Tells the server to log out, and also opens a new window to the logout URL to get Cognito to + * clear its cookies. The new window will hit our page which will close the window automatically. + */ +Login.prototype.logout = function() { + // We both log out the server, and for hosted version, visit a logout URL to clear AWS cookies. + if (window.isRunningUnderElectron) { + // Under electron, only clear the server state. Don't open the user's default browser + // to clear cookies there because it serves dubious purpose and is annoying to the user. + return this.comm.logout(null); + } else { + // In hosted / dev version, we redirect to the logout URL, which will clear cookies and + // redirect back to the starting URL when logout completes. + return this.comm.logout(window.location.href) + .then((logoutUrl) => { window.location.href = logoutUrl; }); + } +}; + +/** + * Retrieves the updated user profile from DynamoDB and creates the profile form. + * Also sends the fetched user profile to the server to keep it up to date. + */ +Login.prototype.createProfileForm = function() { + // ProfileForm disposes itself, no need to handle disposal. + this.profileForm = ProfileForm.create(this); +}; + +// Called when the user logs out in this or another tab. +Login.prototype.onLogout = function() { + this._profile(null); + this.isLoggedIn(false); +}; + +/** + * Update the internally-stored profile given a profile object from the server. + */ +Login.prototype.updateProfileFromServer = function(profileObj) { + this._profile(profileObj); + this.isLoggedIn(Boolean(this._profile.peek())); +}; + +Login.prototype.setProfileItem = function(key, val) { + return this.comm.updateProfile({[key]: val}); +}; + +/** + * Returns an array of tableIds in the basket of the current document. If the current + * document has no basket, an empty array is returned. + */ +Login.prototype.getBasketTables = function(docComm) { + return docComm.getBasketTables(); +}; + +// Execute func if the user is logged in. Otherwise, prompt the user to log in +// and then execute the function. Attempts refresh if the token is expired. +Login.prototype.tryWithLogin = function(func) { + return Promise.try(() => { + if (!this.isLoggedIn()) { + return this.login(); + } + }) + .then(() => func()) + .catch(err => { + if (err.code === 'LoginClosedError') { + console.log("Login#tryWithLogin", err); + } else { + throw err; + } + }); +}; + +module.exports = Login; diff --git a/app/client/components/ModalDialog.js b/app/client/components/ModalDialog.js new file mode 100644 index 00000000..4872b5ee --- /dev/null +++ b/app/client/components/ModalDialog.js @@ -0,0 +1,125 @@ +/* global $, document, window */ + +var _ = require('underscore'); +var ko = require('knockout'); +var BackboneEvents = require('backbone').Events; +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var Base = require('./Base'); + +/** + * ModalDialog constructor creates a new ModalDialog element. The dialog accepts a bunch of + * options, and the content can be overridde when calling .show(), so that a single ModalDialog + * component can be used for different purposes. It triggers a 'close' event (using + * Backbone.Events) when hidden. + * + * The DOM of the dialog is always attached to document.body. + * + * @param {Boolean} [options.fade] Include `fade` css class to fade the modal in/out. + * @param {Boolean} [options.close] Include a close icon in the corner (default true). + * @param {Boolean} [options.backdrop] Include a modal-backdrop element (default true). + * @param {Boolean} [options.keyboard] Close the modal on Escape key (default true). + * @param {Boolean} [options.show] Shows the modal when initialized (default true). + * @param {CSS String} [options.width] Optional css width to override default. + * @param {DOM|String} [options.title] The content to place in the title. + * @param {DOM|String} [options.body] The content to place in the body. + * @param {DOM|String} [options.footer] The content to place in the footer. + */ +function ModalDialog(options) { + Base.call(this, options); + options = options || {}; + this.options = _.defaults(options, { + fade: false, // Controls whether the model pops or fades into view + close: true, // Determines whether the "x" dismiss icon appears in the modal + backdrop: true, + keyboard: true, + show: false, + }); + + // If the width option is set, the margins must be set to auto to keep the dialog centered. + this.style = options.width ? + `width: ${this.options.width}; margin-left: auto; margin-right: auto;` : ''; + this.title = ko.observable(options.title || null); + this.body = ko.observable(options.body || null); + this.footer = ko.observable(options.footer || null); + + this.modal = this.autoDispose(this._buildDom()); + document.body.appendChild(this.modal); + $(this.modal).modal(_.pick(this.options, 'backdrop', 'keyboard', 'show')); + + // On applyState event, close the modal. + this.onEvent(window, 'applyState', () => this.hide()); + + // If disposed, let the underlying JQuery Modal run its hiding logic, and trigger 'close' event. + this.autoDisposeCallback(this.hide); +} +Base.setBaseFor(ModalDialog); +_.extend(ModalDialog.prototype, BackboneEvents); + +/** + * Shows the ModalDialog. It accepts the same `title`, `body`, and `footer` options as the + * constructor, which will replace previous content of those sections. + */ +ModalDialog.prototype.show = function(options) { + options = options || {}; + // Allow options to specify new title, body, and footer content. + ['title', 'body', 'footer'].forEach(function(prop) { + if (options.hasOwnProperty(prop)) { + this[prop](options[prop]); + } + }, this); + + $(this.modal).modal('show'); +}; + +/** + * Hides the ModalDialog. This triggers the `close` to be triggered using Backbone.Events. + */ +ModalDialog.prototype.hide = function() { + $(this.modal).modal('hide'); +}; + +/** + * Internal helper to build the DOM of the dialog. + */ +ModalDialog.prototype._buildDom = function() { + var self = this; + // The .clipboard_focus class tells Clipboard.js to let this component have focus. Otherwise + // it's impossible to select text. + return dom('div.modal.clipboard_focus', + { "role": "dialog", "tabIndex": -1 }, + + // Emit a 'close' Backbone.Event whenever the dialog is hidden. + dom.on('hidden.bs.modal', function() { + self.trigger('close'); + }), + + dom('div.modal-dialog', { style: this.style }, + dom('div.modal-content', + kd.toggleClass('fade', self.options.fade), + + kd.maybe(this.title, function(title) { + return dom('div.modal-header', + kd.maybe(self.options.close, function () { + return dom('button.close', + {"data-dismiss": "modal", "aria-label": "Close"}, + dom('span', {"aria-hidden": true}, '×') + ); + }), + dom('h4.modal-title', title) + ); + }), + + kd.maybe(this.body, function(body) { + return dom('div.modal-body', body); + }), + + kd.maybe(this.footer, function(footer) { + return dom('div.modal-footer', footer); + }) + ) + ) + ); +}; + +module.exports = ModalDialog; diff --git a/app/client/components/ParseOptions.ts b/app/client/components/ParseOptions.ts new file mode 100644 index 00000000..843480a6 --- /dev/null +++ b/app/client/components/ParseOptions.ts @@ -0,0 +1,118 @@ +import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {squareCheckbox} from 'app/client/ui2018/checkbox'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {cssModalButtons} from 'app/client/ui2018/modals'; +import {ParseOptionSchema} from 'app/plugin/FileParserAPI'; +import {Computed, dom, DomContents, IDisposableOwner, input, Observable, styled} from 'grainjs'; +import fromPairs = require('lodash/fromPairs'); +import invert = require('lodash/invert'); + +export type ParseOptionValueType = boolean|string|number; + +export interface ParseOptionValues { + [name: string]: ParseOptionValueType; +} + +/** + * EscapeChars contains mapping for some escape characters that we need to convert + * for displaying in input fields + */ +interface EscapeChars { + [char: string]: string; +} + +const escapeCharDict: EscapeChars = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', +}; +const invertedEscapeCharDict: EscapeChars = invert(escapeCharDict); + +// Helpers to escape and unescape certain non-printable characters that are useful in parsing +// options, e.g. as separators. +function escapeChars(value: string) { + return value.replace(/[\n\r\t]/g, (match) => escapeCharDict[match]); +} +function unescapeChars(value: string) { + return value.replace(/\\[nrt]/g, (match) => invertedEscapeCharDict[match]); +} + +/** + * Builds a DOM form consisting of inputs built according to schema, with the passed-in values. + * The included "Update" button is enabled if any value has changed, and calls doUpdate() with the + * current values. + */ +export function buildParseOptionsForm( + owner: IDisposableOwner, + schema: ParseOptionSchema[], + values: ParseOptionValues, + doUpdate: (v: ParseOptionValues) => void, + doCancel: () => void, +): DomContents { + const items = schema.filter(item => item.visible); + const optionsMap = new Map>( + items.map((item) => [item.name, Observable.create(owner, values[item.name])])); + + function collectParseOptions(): ParseOptionValues { + return fromPairs(items.map((item) => [item.name, optionsMap.get(item.name)!.get()])); + } + + return [ + cssParseOptionForm( + items.map((item) => cssParseOption( + cssParseOptionName(item.label), + optionToInput(owner, item.type, optionsMap.get(item.name)!), + testId('parseopts-opt'), + )), + ), + cssModalButtons( + dom.domComputed((use) => items.every((item) => use(optionsMap.get(item.name)!) === values[item.name]), + (unchanged) => (unchanged ? + bigBasicButton('Back to preview', dom.on('click', doCancel), testId('parseopts-back')) : + bigPrimaryButton('Update preview', dom.on('click', () => doUpdate(collectParseOptions())), + testId('parseopts-update')) + ) + ) + ), + ]; +} + +function optionToInput(owner: IDisposableOwner, type: string, value: Observable): HTMLElement { + switch (type) { + case 'boolean': return squareCheckbox(value as Observable); + default: { + const obs = Computed.create(owner, (use) => escapeChars(String(use(value) || ""))) + .onWrite((val) => value.set(unescapeChars(val))); + return cssInputText(obs, {onInput: true}, + dom.on('focus', (ev, elem) => elem.select())); + } + } +} + +const cssParseOptionForm = styled('div', ` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 16px 0; + width: 400px; + overflow-y: auto; +`); +const cssParseOption = styled('div', ` + flex: none; + margin: 8px 0; + width: calc(50% - 16px); + font-weight: initial; /* negate bootstrap */ +`); +const cssParseOptionName = styled('div', ` + margin-bottom: 8px; +`); +const cssInputText = styled(input, ` + position: relative; + display: inline-block; + outline: none; + height: 28px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + padding: 0 6px; + width: 100%; +`); diff --git a/app/client/components/Preferences.css b/app/client/components/Preferences.css new file mode 100644 index 00000000..dae33464 --- /dev/null +++ b/app/client/components/Preferences.css @@ -0,0 +1,43 @@ +.preference_mask { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 10; + background-color: rgba(1, 1, 1, 0.3); +} + +.preference_window { + position: absolute; + background-color: white; + display: inline-block; + padding: 10px 20px; + width: 60%; + margin: 20%; + box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.8); +} + +.preference_title { + color: #546e7a; + text-align: left; + border-bottom: 1px solid rgba(0,0,0,.12); + font-family: 'Roboto', Helvetica, Arial, sans-serif; + width: 100%; + height: 40px; +} + +.preference_content { + width: 100%; + font-size: 1.4rem; +} + +.preference_desc { + margin-left: 5px; + cursor: pointer; + font-weight: normal; +} + +.preference_footer > .kf_elem { + flex: 1 1 100%; +} diff --git a/app/client/components/ProfileForm.js b/app/client/components/ProfileForm.js new file mode 100644 index 00000000..723f94d0 --- /dev/null +++ b/app/client/components/ProfileForm.js @@ -0,0 +1,160 @@ +/* global document */ + +// External dependencies +const _ = require('underscore'); +const ko = require('knockout'); +const BackboneEvents = require('backbone').Events; + +// Grist client libs +const dispose = require('../lib/dispose'); +const dom = require('../lib/dom'); +const kf = require('../lib/koForm'); +const kd = require('../lib/koDom'); +const ModalDialog = require('./ModalDialog'); + +/** + * ProfileForm - Handles dom and settings for the profile box. + * @param {Login} login - The login instance. + */ +function ProfileForm(login) { + this._login = login; + this._comm = this._login.comm; + this._gristLogin = this._login.gristLogin; + this._errorNotify = ko.observable(); + this._successNotify = ko.observable(); + + // Form data which may be filled in when modifying profile information. + this._newName = ko.observable(''); + + // Counter used to provide each edit profile sub-form with an id which indicates + // when it is visible. + this._formId = 1; + this._editingId = ko.observable(null); + + this._profileDialog = this.autoDispose(ModalDialog.create({ + title: 'User profile', + body: this._buildProfileDom(), + width: '420px' + })); + this._profileDialog.show(); + + // TODO: Some indication is necessary that verification is occurring between + // submitting the form and waiting for the box to close. + this.listenTo(this._comm, 'clientLogout', () => this.dispose()); + this.listenTo(this._profileDialog, 'close', () => this.dispose()); +} +_.extend(ProfileForm.prototype, BackboneEvents); +dispose.makeDisposable(ProfileForm); + +/** + * Builds the body of the profile modal form. + */ +ProfileForm.prototype._buildProfileDom = function() { + return dom('div.profile-form', + // Email + // TODO: Allow changing email + this._buildProfileRow('Email', { + buildDisplayFunc: () => dom('div', + kd.text(this._login.emailObs), + dom.testId('ProfileForm_viewEmail') + ) + }), + // Name + this._buildProfileRow('Name', { + buildDisplayFunc: () => dom('div', + kd.text(this._login.nameObs), + dom.testId('ProfileForm_viewName') + ), + buildEditFunc: () => dom('div', + kf.label('New name'), + kf.text(this._newName, {}, dom.testId('ProfileForm_newName')) + ), + submitFunc: () => this._submitNameChange() + }), + // TODO: Allow editing profile image. + kd.maybe(this._successNotify, success => { + return dom('div.login-success-notify', + dom('div.login-success-text', success) + ); + }), + kd.maybe(this._errorNotify, err => { + return dom('div.login-error-notify', + dom('div.login-error-text', err) + ); + }) + ); +}; + +/** + * Builds a row of the profile form. + * @param {String} label - Indicates the profile item displayed by the row. + * @param {Function} options.buildDisplayFunc - A function which returns dom representing + * the value of the profile item to be displayed. If omitted, no value is visible. + * @param {Function} options.buildEditFunc - A function which returns dom to change the + * value of the profile item. If omitted, the profile item may not be edited. + * @param {Function} options.submitFunc - A function to call to save changes to the + * profile item. MUST be included if buildEditFunc is included. + */ +ProfileForm.prototype._buildProfileRow = function(label, options) { + options = options || {}; + let formId = this._formId++; + + return dom('div.profile-row', + kf.row( + 2, kf.label(label), + 5, options.buildDisplayFunc ? options.buildDisplayFunc() : '', + 1, dom('div.btn.edit-profile.glyphicon.glyphicon-pencil', + { style: `visibility: ${options.buildEditFunc ? 'visible' : 'hidden'}` }, + dom.testId(`ProfileForm_edit${label}`), + dom.on('click', () => { + this._editingId(this._editingId() === formId ? null : formId); + }) + ) + ), + kd.maybe(() => this._editingId() === formId, () => { + return dom('div', + dom.on('keydown', e => { + if (e.keyCode === 13) { + // Current element is likely a knockout text field with changes that haven't yet been + // saved to the observable. Blur the current element to ensure its value is saved. + document.activeElement.blur(); + options.submitFunc(); + } + }), + dom('div.edit-profile-form', + options.buildEditFunc(), + dom('div.login-btns.flexhbox', + kf.buttonGroup( + kf.button(() => this._editingId(null), 'Cancel', + dom.testId('ProfileForm_cancel')) + ), + kf.buttonGroup( + kf.accentButton(() => options.submitFunc(), 'Submit', + dom.testId('ProfileForm_submit')) + ) + ) + ) + ); + }) + ); +}; + +// Submits the profile name change form. +ProfileForm.prototype._submitNameChange = function() { + if (!this._newName()) { + throw new Error('Name may not be blank.'); + } + return this._login.setProfileItem('name', this._newName()) + // TODO: attemptRefreshToken() should be handled in a general way for all methods + // which require using tokens after sign in. + .then(() => { + this._editingId(null); + this._successNotify('Successfully changed name.'); + }) + .catch(err => { + console.error('Error changing name', err); + this._errorNotify(err.message); + }); +}; + +module.exports = ProfileForm; diff --git a/app/client/components/REPLTab.css b/app/client/components/REPLTab.css new file mode 100644 index 00000000..0edac241 --- /dev/null +++ b/app/client/components/REPLTab.css @@ -0,0 +1,106 @@ +.repl-container { + padding: 0 10px; +} + +.repl-text { + cursor: pointer; + display: inline-block; + font-family: monospace; + white-space: pre; + tab-size: 4; + -moz-tab-size: 4; + -o-tab-size: 4; + word-wrap: break-word; +} +.repl-field { + display: inline-block; +} +.repl-error { + color: #D00; +} +.repl-text_line:hover { + background-color: #E5E5E5; +} + +.re-eval_line_button { + cursor: pointer; + float: right; + text-align: center; + width: 15px; + color: #808080; +} +.erase_line_button { + cursor: pointer; + float: right; + text-align: center; + width: 15px; + color: #808080; +} +.re-eval_line_button:hover { + color: #000000; +} +.erase_line_button:hover { + color: #000000; +} + +.unselectable { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.pointer_group { + cursor: pointer; + display: inline-block; + width: 25px; +} +.pointer { + font-family: monospace; + font-weight: bold; +} + +.repl-newline { + font-family: monospace; + font-weight: bold; + padding: 2px 10px 15px 10px; +} + +.repl-cursor_editor { + display: inline; +} +.repl-text_editor { + position: absolute; + padding: 2px 0 0 0; + min-width: 10px; + min-height: 100%; + width: 100%; + height: 100%; + border: none; + resize: none; + box-shadow: none; + outline: none; + background: transparent; + z-index: 10; + overflow: hidden; +} + +.repl-content_measure { + position: absolute; + left: 0; + top: 0; + padding-top: 2px; + padding-right: 1em; + border: none; + visibility: hidden; + overflow: visible; +} + +.formula-text { + font-family: monospace; + tab-size: 4; + -moz-tab-size: 4; + -o-tab-size: 4; +} diff --git a/app/client/components/REPLTab.js b/app/client/components/REPLTab.js new file mode 100644 index 00000000..ead3aff5 --- /dev/null +++ b/app/client/components/REPLTab.js @@ -0,0 +1,318 @@ +/** + * A Tab that contains a REPL. + * The REPL allows the user to write snippets of python code and see the results of evaluating + * them. In particular, the REPL has access to the usercode module, so they can see the results + * of quick operations on their data. + * The REPL supports evaluation of code, removal of lines from history, and re-computation + * and editing of older lines. + */ +var kd = require('../lib/koDom'); +var ko = require('knockout'); +var dom = require('../lib/dom'); +var Base = require('./Base'); +var commands = require('./commands'); + +var NEW_LINE = -1; + +/** + * Hard tab used instead of soft tabs, as soft-tabs would require a lot of additional + * editor logic (partial-width tabs, backspacing a tab, ...) for + * which we may want to eventually use a 3rd-party library for in addition to syntax highlighting, etc + */ +var INDENT_STR = "\t"; + +function REPLTab(gristDoc) { + Base.call(this, gristDoc); + this.replHist = gristDoc.docModel.replHist.createAllRowsModel("id"); + this.docData = gristDoc.docData; + this.editingIndex = ko.observable(null); + this.histIndex = ko.observable(this.replHist.peekLength); + + this.editorActive = ko.observable(false); + this.numLines = ko.observable(0); + this.row = null; + + this._contentSizer = ko.observable(''); + this._originalValue = ''; + this._textInput = null; + + this.commandGroup = this.autoDispose(commands.createGroup( + REPLTab.replCommands, this, this.editorActive)); +} + +Base.setBaseFor(REPLTab); + +/** + * Editor commands for the cellEditor in the REPL Tab + * TODO: Using the command group, distinguish between "on enter" saves and "on blur" saves + * So that we can give up focus on blur + */ +REPLTab.replCommands = { + // TODO: GridView commands are activated more recently after startup. + fieldEditSave: function() { + if (!this._textInput || !this.editorActive() || + !this._textInput.value.trim() && this.editingIndex() === NEW_LINE) { return; } + // TODO: Scroll pane does not automatically scroll down on save. + var self = this; + this.save() + .then(function(success) { + if (success) { + self.editingIndex(NEW_LINE); + self.clear(); + // Refresh the history index. + self.histIndex(self.replHist.peekLength); + } else { + self.write("\n"); + // Since focus is staying in the current input, increment lines. + self.numLines(self.numLines.peek()+1); + } + }); + }, + fieldEditCancel: function() { + this.clear(); + this.editingIndex(NEW_LINE); + }, + nextField: function() { + // In this case, 'nextField' (Tab) inserts a tab. + this.write(INDENT_STR); + }, + historyPrevious: function() { + // Fills the editor with the code previously entered. + if (this.editingIndex() === NEW_LINE) { this.writePrev(); } + }, + historyNext: function() { + // Fills the editor with the code entered after the current code. + if (this.editingIndex() === NEW_LINE) { this.writeNext(); } + } +}; + + +/** + * Sends the entered code as an EvalCode Useraction. + * @param {Function} callback - Is called with a single argument 'success' indicating + * whether the save was successful. + */ +REPLTab.prototype.save = function(callback) { + if (!this._textInput.value.trim()) { + // If its text is cleared, remove history item. + var currentEditIndex = this.editingIndex(); + this.histIndex(this.replHist.peekLength - 1); + this.editorActive(false); + return this.docData.sendAction(["RemoveRecord", "_grist_REPL_Hist", currentEditIndex]); + } + else { + // If something is entered, save value. + var rowId = this.row ? this.row.id() : null; + return this.docData.sendAction(["EvalCode", this._textInput.value, rowId]); + } +}; + +// Builds object with REPLTab dom builder and settings for the sidepane. +REPLTab.prototype.buildConfigDomObj = function() { + return [{ + 'buildDom': this.buildDom.bind(this), + 'keywords': ['repl', 'console', 'python', 'code', 'terminal'] + }]; +}; + +REPLTab.prototype.buildDom = function() { + var self = this; + return dom('div', + kd.foreach(this.replHist, function(replLine) { + return dom('div.repl-container', + dom('div.repl-text_line', + kd.scope(function() { return self.editingIndex() === replLine.id(); }, + function(isEditing) { + if (isEditing) { + return dom('div.field.repl-field', + kd.scope(self.numLines, function(numLines) { + return self.buildPointerGroup(numLines); + }), + self.attachEditorDom(replLine)); + } else { + var numLines = replLine.code().trim().split('\n').length; + return dom('div.repl-field', + dom.on('click', function() { + // TODO: Flickering occurs on click for multiline code segments. + self.editingIndex(replLine.id()); + self.focus(); + }), + self.buildPointerGroup(numLines), + dom('div.repl-text', + kd.text(replLine.code) + ) + ); + } + } + ), + dom('div.erase_line_button.unselectable', dom.on('click', function() { + self.histIndex(self.replHist.peekLength - 1); + return self.docData.sendAction( + ["RemoveRecord", "_grist_REPL_Hist", replLine.id()] + ); + }), '\u2A09'), + dom('div.re-eval_line_button.unselectable', dom.on('click', function() { + return self.docData.sendAction( + ["EvalCode", replLine.code(), replLine.id()] + ); + }), '\u27f3') // 'refresh' symbol + ), + kd.maybe(replLine.outputText, function() { + return dom('div.repl-text.repl-output', kd.text(replLine.outputText)); + }), + kd.maybe(replLine.errorText, function() { + return dom('div.repl-text.repl-error', kd.text(replLine.errorText)); + }) + ); + }), + // Special bottom editor which sends actions to add new records to the REPL hist. + dom('div.repl-newline', + dom.on('click', function() { + self.editingIndex(NEW_LINE); + self.focus(); + }), + dom('div.field.repl-field', + kd.scope(self.numLines, function(numLines) { + return self.buildPointerGroup(self.editingIndex() === NEW_LINE ? numLines : 1); + }), + kd.maybe(ko.pureComputed(function() { return self.editingIndex() === NEW_LINE; }), + function() { return self.attachEditorDom(null); } + ) + ) + ) + ); +}; + +/** + * Builds the set of pointers to the left of the code + * @param {String} code - The code for which the pointer group is to be built. + */ +REPLTab.prototype.buildPointerGroup = function(numLines) { + var pointers = []; + for (var i = 0; i < numLines; i++) { + pointers.push(dom('div.pointer', i ? '...' : '>>>')); + } + return dom('div.pointer_group.unselectable', pointers); +}; + +REPLTab.prototype.buildEditorDom = function() { + var self = this; + return dom('div.repl-cursor_editor', + dom('div.repl-content_measure.formula-text', kd.text(this._contentSizer)), + function() { + self._textInput = dom('textarea.repl-text_editor.formula-text', + kd.value(self.row ? self.row.code() : ""), + dom.on('focus', function() { + self.numLines(this.value.split('\n').length); + }), + dom.on('blur', function() { + if (!this._textInput || !this.editorActive()) { return; } + self.save() + .then(function(success) { + if (success) { + // If editing a new line, clear it to start fresh. + if (self.editingIndex() === NEW_LINE) { self.clear(); } + // Refresh the history index. + self.histIndex(self.replHist.peekLength); + } else { + self.write("\n"); + } + self.editorActive(false); + }); + }), + //Resizes the textbox whenever user writes in it. + dom.on('input', function() { + self.numLines(this.value.split('\n').length); + self.resizeElem(); + }), + dom.defer(function(elem) { + self.resizeElem(); + elem.focus(); + // Set the cursor at the end. + var elemLen = elem.value.length; + elem.selectionStart = elemLen; + elem.selectionEnd = elemLen; + }), + dom.on('mouseup mousedown click', function(event) { event.stopPropagation(); }), + self.commandGroup.attach() + ); + return self._textInput; + } + ); +}; + +/** +* This function measures a hidden div with the same value as the textarea being edited and then resizes the textarea to match. +*/ +REPLTab.prototype.resizeElem = function() { + // \u200B is a zero-width space; it is used so the textbox will expand vertically + // on newlines, but it does not add any width the string + this._contentSizer(this._textInput.value + '\u200B'); + var rect = this._textInput.parentNode.childNodes[0].getBoundingClientRect(); + //Allows form to expand passed its container div. + this._textInput.style.width = Math.ceil(rect.width) + 'px'; + this._textInput.style.height = Math.ceil(rect.height) + 'px'; +}; + +/** + * Appends text to the contents being edited + */ +REPLTab.prototype.write = function(text) { + this._textInput.value += text; + this.resizeElem(); +}; + +/** + * Clears both the current text and any memory of text in the currently edited cell. + */ +REPLTab.prototype.clear = function() { + this._textInput.value = ""; + this._orignalValue = ""; + this.numLines(1); + this.resizeElem(); +}; + +/** + * Restores focus to the most recent input. + */ +REPLTab.prototype.focus = function() { + if (this._textInput) { + this._textInput.focus(); + this.editorActive(true); + } +}; + +/** + * Writes the code entered before the current code to the input. + */ +REPLTab.prototype.writePrev = function() { + this.histIndex(Math.max(this.histIndex.peek() - 1, 0)); + this.clear(); + if (this.replHist.at(this.histIndex.peek())) { + this.write(this.replHist.at(this.histIndex.peek()).code()); + } +}; + +/** + * Writes the code entered after the current code to the input. + */ +REPLTab.prototype.writeNext = function() { + this.histIndex(Math.min(this.histIndex() + 1, this.replHist.peekLength)); + this.clear(); + if (this.histIndex.peek() < this.replHist.peekLength) { + this.write(this.replHist.at(this.histIndex.peek()).code()); + } +}; + +/** +* This function is called in the DOM element where an editor is desired. +* It attaches to as a child of that element with that elements value as default or whatever is set as an override value. +*/ +REPLTab.prototype.attachEditorDom = function(row) { + var self = this; + self.row = row; + self._originalValue = self.row ? self.row.code() : ""; + return self.buildEditorDom(); +}; + +module.exports = REPLTab; diff --git a/app/client/components/RecordLayout.css b/app/client/components/RecordLayout.css new file mode 100644 index 00000000..47083477 --- /dev/null +++ b/app/client/components/RecordLayout.css @@ -0,0 +1,46 @@ +.g_record_layout_leaf { + width: 100%; +} + +.g_record_layout_editing { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + cursor: move; + z-index: 5; + + background-color: rgba(192, 192, 192, 0.2); + border-left: 1px solid white; + border-top: 1px solid white; + border-right: 1px solid var(--grist-color-dark-grey); + border-bottom: 1px solid var(--grist-color-dark-grey); +} + +.dropdown-menu .g_record_layout_newfield { + margin: 2px 1rem; + padding: 0px 0.5rem; + border: 2px outset rgba(160, 160, 255, 0.5); + background-color: rgba(233, 233, 233, 0.5); + cursor: move; + color: #666; + font-size: 1.2rem; +} + +.g_record_delete_field { + position: absolute; + top: 0; + right: 0; + background-color: #404040; + border: 1px solid #404040; + border-radius: 1rem; + color: white; + cursor: pointer; + + display: none; +} + +.g_record_layout_editing:hover > .g_record_delete_field { + display: block; +} diff --git a/app/client/components/RecordLayout.js b/app/client/components/RecordLayout.js new file mode 100644 index 00000000..25bd5ac8 --- /dev/null +++ b/app/client/components/RecordLayout.js @@ -0,0 +1,353 @@ +/** + * Module for displaying a record of user data in a two-dimentional editable layout. + */ + + +// TODO: +// 1. Consider a way to upgrade a file to add layoutSpec column to the ViewSections meta table. +// Plan: add docInfo schemaVersion field. +// when opening a file, let the sandbox check the version and check if loaded metadata matches the schema. +// sandbox should return doc-version, current-version, and match status. +// if current-version != doc_version [AND mismatch] (this is optional, let's think if we +// want that), then +// Sandbox creates new temp document +// Replays action log into it. +// Renames it over the old document. [Would be nice to ask the user first] +// Reopen document +// 1. [LATER] Create RecordLayout file with APIs to support more efficient big list of laid-out +// records (so that a single RecordLayout can maintain many Layout instances). +// 2. [LATER] Allow dragging in boxes from the view config. +// 3. [LATER] Allow creating new field and inserting at the bottom. +// 4. [LATER] Allow selecting existing field from context menu and inserting. +// 5. [LATER] Add interface to Layout to tab forward and back, left, right, up, down, and use that in +// detail view. +// 6. [LATER] Implement saving and loading of widths in the layout spec. + +var _ = require('underscore'); +var ko = require('knockout'); +var Promise = require('bluebird'); + +var gutil = require('app/common/gutil'); +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var {Delay} = require('../lib/Delay'); +var kd = require('../lib/koDom'); + +var Layout = require('./Layout'); +var RecordLayoutEditor = require('./RecordLayoutEditor'); + +/** + * Construct a RecordLayout. + * @param {MetaRowModel} options.viewSection: The model for the viewSection represented. + * @param {Function} options.buildFieldDom: Function called with (viewField) that should + * return the DOM for that field. + * @param {Function} options.resizeCallback: Optional function called with no arguments when + * the RecordLayout is modified in a way that may require resizing. + */ +function RecordLayout(options) { + this.viewSection = options.viewSection; + this.buildFieldDom = options.buildFieldDom; + this.isEditingLayout = ko.observable(false); + this.editIndex = ko.observable(0); + this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active. + + if (options.resizeCallback) { + this._resizeCallback = options.resizeCallback; + this._delayedResize = this.autoDispose(Delay.create()); + } + + // Observable object that will be rebuilt whenever the list of viewFields changes. + this.fieldsById = this.autoDispose(ko.computed(function() { + return _.indexBy(this.viewSection.viewFields().all(), + function(field) { return field.getRowId(); }); + }, this)); + + // Update the stored layoutSpecObj with any missing fields that are present in viewFields. + this.layoutSpec = this.autoDispose(ko.computed(function() { + return RecordLayout.updateLayoutSpecWithFields( + this.viewSection.layoutSpecObj(), this.viewSection.viewFields().all()); + }, this).extend({rateLimit: 0})); // layoutSpecObj and viewFields should be updated together. + this.autoDispose(this.layoutSpec.subscribe(() => this.resizeCallback())); + + // TODO: We may want a context menu for each record, but the previous implementation wasn't + // working, and was creating a separate context menu for each row, which is very expensive. A + // better approach is to create a single context menu for the view section, as GridView does. +} +dispose.makeDisposable(RecordLayout); + + +RecordLayout.prototype.resizeCallback = function() { + // Note that while editing layout, scrolly is hidden, and resizeCallback is unhelpful. We rely + // on explicit resizing when isEditLayout is reset. + if (!this.isDisposed() && this._delayedResize && !this.isEditingLayout.peek()) { + this._delayedResize.schedule(0, this._resizeCallback); + } +}; + +RecordLayout.prototype.getField = function(fieldRowId) { + // If fieldRowId is a string, then it's actually "colRef:label:value" placeholder that we use + // when adding a new field. If so, return a special object with the fields available. + if (typeof fieldRowId === 'string') { + var parts = gutil.maxsplit(fieldRowId, ":", 2); + return { + isNewField: true, // To make it easy to distinguish from a ViewField MetaRowModel + colRef: parseInt(parts[0], 10), + label: parts[1], + value: parts[2] + }; + } + return this.fieldsById()[fieldRowId]; +}; + + +/** + * Sets the layout to being edited. + */ +RecordLayout.prototype.editLayout = function(rowIndex) { + this.editIndex(rowIndex); + this.isEditingLayout(true); +}; + +/** + * Ends layout editing, without updating the layout on the server. + */ +RecordLayout.prototype.onEditLayoutCancel = function(layoutSpec) { + this.isEditingLayout(false); + // Call resizeCallback here, since it's possible that theme was also changed (and auto-saved) + // even though the layout itself was reverted. + this.resizeCallback(); +}; + +/** + * Ends layout editing, and saves the given layoutSpec to the server. + */ +RecordLayout.prototype.onEditLayoutSave = async function(layoutSpec) { + try { + await this.saveLayoutSpec(layoutSpec); + } finally { + this.isEditingLayout(false); + this.resizeCallback(); + } +}; + +/** + * If there is no layout saved, we can create a default layout just from the list of fields for + * this view section. By default we just arrange them into a list of rows, two fields per row. + */ +RecordLayout.updateLayoutSpecWithFields = function(spec, viewFields) { + // We use tmpLayout as a way to manipulate the layout before we get a final spec from it. + var tmpLayout = Layout.Layout.create(spec, function(leafId) { return dom('div'); }); + + var specFieldIds = tmpLayout.getAllLeafIds(); + var viewFieldIds = viewFields.map(function(f) { return f.getRowId(); }); + + // For any stale fields (no longer among viewFields), remove them from tmpLayout. + _.difference(specFieldIds, viewFieldIds).forEach(function(leafId) { + tmpLayout.getLeafBox(leafId).dispose(); + }); + + // For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a + // two-column layout, so add a new row, or a second box to the last row if it's a leaf. + _.difference(viewFieldIds, specFieldIds).forEach(function(leafId) { + var newBox = tmpLayout.buildLayoutBox({ leaf: leafId }); + var rows = tmpLayout.rootBox().childBoxes.peek(); + if (rows.length >= 1 && _.last(rows).isLeaf()) { + // Add a new child to the last row. + _.last(rows).addChild(newBox, true); + } else { + // Add a new row. + tmpLayout.rootBox().addChild(newBox, true); + } + }); + + spec = tmpLayout.getLayoutSpec(); + tmpLayout.dispose(); + return spec; +}; + +/** + * Saves the layout spec as build by the user. This is quite involved, because it may need to + * remove fields as well as create fields and possibly new columns. And it needs the results of + * these operations to update the spec before saving it. + */ +RecordLayout.prototype.saveLayoutSpec = function(layoutSpec) { + // The layout hasn't actually changed. Skip the rest to avoid creating no-op actions (the + // resulting no-op undo would be particularly confusing). + if (JSON.stringify(layoutSpec) === this.viewSection.layoutSpec.peek()) { + return; + } + + const docModel = this.viewSection._table.docModel; + const docData = docModel.docData; + const tableId = this.viewSection.table().tableId(); + const getField = fieldRef => this.getField(fieldRef); + const addColAction = ["AddColumn", null, {}]; + + // Build a set of fieldRefs (i.e. rowIds) that are currently stored. Also build a map of colRef + // to fieldRef, so that we can restore a field that got removed and re-added (as a colRef). + var origRefs = []; + var colRefToFieldRef = new Map(); + this.viewSection.viewFields().all().forEach(f => { + origRefs.push(f.getRowId()); + colRefToFieldRef.set(f.colRef(), f.getRowId()); + }); + + // Initialize leaf index counter and num cols to be added counter. + var nextPos = 0; + var addColNum = 0; + + // Initialize arrays to keep track of existing field refs and their updated positions. + var existingRefs = []; + var existingPositions = []; + + // Initialize arrays to keep track of added fields for existing but hidden columns. + var hiddenColRefs = []; + var hiddenCallbacks = []; + var hiddenPositions = []; + + // Initialize arrays to keep track of newly added columns. + var addedCallbacks = []; + var addedPositions = []; + + // Recursively process all layoutBoxes in the spec. Sets up bookkeeping arrays for + // exisiting fields and added fields for new/hidden cols from which the action bundle will + // be created. + function processBox(spec) { + // "empty" is a temporary placeholder used by LayoutEditor, and not a valid leaf. + if (spec.leaf && spec.leaf !== "empty") { + let pos = nextPos++; + let field = getField(spec.leaf); + let updateLeaf = ref => { spec.leaf = ref; }; + if (!field.isNewField) { + // Existing field. + existingRefs.push(field.getRowId()); + existingPositions.push(pos); + } else if (colRefToFieldRef.has(field.colRef)) { + // Existing field that got removed and re-added. + let fieldRef = colRefToFieldRef.get(field.colRef); + existingRefs.push(fieldRef); + existingPositions.push(pos); + updateLeaf(fieldRef); + } else if (Number.isNaN(field.colRef)) { + // We need to add a new column AND field. + addColNum++; + addedCallbacks.push(updateLeaf); + addedPositions.push(pos); + } else { + // We need to add a field for an existing column. + hiddenColRefs.push(field.colRef); + hiddenCallbacks.push(updateLeaf); + hiddenPositions.push(pos); + } + } + if (spec.children) { + spec.children.map(processBox); + } + } + processBox(layoutSpec); + + // Combine data for item which require both new columns and new fields and only new fields, + // with items which require new columns first. + let callbacks = addedCallbacks.concat(hiddenCallbacks); + let positions = addedPositions.concat(hiddenPositions); + let addActions = gutil.arrayRepeat(addColNum, addColAction); + + docData.startBundlingActions('Updating record layout.', action => { + return [tableId, '_grist_Views_section', '_grist_Views_section_field'].includes(action[1]); + }); + return Promise.try(() => { + return addColNum > 0 ? docModel.dataTables[tableId].sendTableActions(addActions) : []; + }) + .then(results => { + let colRefs = results.map(r => r.colRef).concat(hiddenColRefs); + const addFieldNum = colRefs.length; + // Add fields for newly added columns and previously hidden columns. + return addFieldNum > 0 ? + docModel.viewFields.sendTableAction(["BulkAddRecord", gutil.arrayRepeat(addFieldNum, null), { + parentId: gutil.arrayRepeat(addFieldNum, this.viewSection.getRowId()), + colRef: colRefs, + parentPos: positions + }]) : []; + }) + .each((fieldRef, i) => { + // Call the stored callback for each fieldRef, which each set the correct layoutSpec leaf + // to the newly obtained fieldRef. + callbacks[i](fieldRef); + }) + .then(addedRefs => { + let actions = []; + + // Records present before that were not present after editing must be removed. + let finishedRefs = new Set(existingRefs.concat(addedRefs)); + let removed = origRefs.filter(fieldRef => !finishedRefs.has(fieldRef)); + if (removed.length > 0) { + actions.push(["BulkRemoveRecord", "_grist_Views_section_field", removed]); + } + + // Positions must be updated for fields which were not added/removed. + if (existingRefs.length > 0) { + actions.push(["BulkUpdateRecord", "_grist_Views_section_field", existingRefs, { + "parentPos": existingPositions + }]); + } + + // And update the layoutSpecObj itself. + actions.push(["UpdateRecord", "_grist_Views_section", this.viewSection.getRowId(), { + "layoutSpec": JSON.stringify(layoutSpec) + }]); + + return docData.sendActions(actions); + }) + .finally(() => docData.stopBundlingActions()); +}; + +/** + * Builds the Layout dom for a single record. + */ +RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) { + const createEditor = Boolean(optCreateEditor && !this.layoutEditor.peek()); + + const layout = Layout.Layout.create(this.layoutSpec(), (fieldRowId) => + dom('div.g_record_layout_leaf.flexhbox.flexauto', + this.buildFieldDom(this.getField(fieldRowId), row), + (createEditor ? + kd.maybe(this.layoutEditor, editor => editor.buildLeafDom()) : + null + ) + ) + ); + + const sub = this.layoutSpec.subscribe((spec) => { layout.buildLayout(spec); }); + + if (createEditor) { + this.layoutEditor(RecordLayoutEditor.create(this, layout)); + } + + return dom('div.g_record_detail.flexauto', + dom.autoDispose(layout), + dom.autoDispose(sub), + createEditor ? dom.onDispose(() => { + this.layoutEditor.peek().dispose(); + this.layoutEditor(null); + }) : null, + dom('div.detail_row_num', kd.text(() => (row._index() + 1))), + dom('div.g_record_detail_inner', layout.rootElem) + ); +}; + +/** + * Returns the viewField row model for the field that the given DOM element belongs to. + */ +RecordLayout.prototype.getContainingField = function(elem, optContainer) { + return this.getField(Layout.Layout.getContainingBox(elem, optContainer).leafId()); +}; + +/** + * Returns the RowModel for the record that the given DOM element belongs to. + */ +RecordLayout.prototype.getContainingRow = function(elem, optContainer) { + var itemElem = dom.findAncestor(elem, optContainer, '.g_record_detail'); + return ko.utils.domData.get(itemElem, 'itemModel'); +}; + +module.exports = RecordLayout; diff --git a/app/client/components/RecordLayoutEditor.js b/app/client/components/RecordLayoutEditor.js new file mode 100644 index 00000000..ab11c7e7 --- /dev/null +++ b/app/client/components/RecordLayoutEditor.js @@ -0,0 +1,150 @@ +var _ = require('underscore'); +var BackboneEvents = require('backbone').Events; + +var dispose = require('app/client/lib/dispose'); +var commands = require('./commands'); +var LayoutEditor = require('./LayoutEditor'); +const {basicButton, cssButton, primaryButton} = require('app/client/ui2018/buttons'); +const {icon} = require('app/client/ui2018/icons'); +const {menu, menuDivider, menuItem} = require('app/client/ui2018/menus'); +const {testId} = require('app/client/ui2018/cssVars'); +const {dom, Observable, styled} = require('grainjs'); + +//---------------------------------------------------------------------- + +/** + * An extension of LayoutEditor which includes commands and the option for a callback function. + * + * Used by RecordLayout.js + * + * @param {layoutSpec} observable - An observable evaluating to the original layoutSpec of the layout. + * @param {optResizeCallback} Function - An optional function to be called after every resize during + * layout editing. + */ +function RecordLayoutEditor(recordLayout, layout, optResizeCallback) { + this.recordLayout = recordLayout; + this.layout = layout; + this.layoutEditor = this.autoDispose(LayoutEditor.LayoutEditor.create(layout)); + this._hiddenColumns = this.autoDispose(Observable.create(null, this.getHiddenColumns())); + + this.listenTo(layout, 'layoutChanged', function() { + this._hiddenColumns.set(this.getHiddenColumns()); + }); + + if (optResizeCallback) { + this.listenTo(layout, 'layoutChanged', optResizeCallback); + this.listenTo(layout, 'layoutResized', optResizeCallback); + } + + // Command group implementing the commands available while editing the layout. + this.autoDispose(commands.createGroup(RecordLayoutEditor.editLayoutCommands, this, true)); +} +dispose.makeDisposable(RecordLayoutEditor); +_.extend(RecordLayoutEditor.prototype, BackboneEvents); + + +/** + * Commands active while editing the record layout. + */ +RecordLayoutEditor.editLayoutCommands = { + accept: function() { + this.recordLayout.onEditLayoutSave(this.layout.getLayoutSpec()); + }, + cancel: function() { + this.layout.buildLayout(this.recordLayout.layoutSpec()); + this.recordLayout.onEditLayoutCancel(); + }, +}; + +/** + * Returns the list of columns that are not included in the current layout. + */ +RecordLayoutEditor.prototype.getHiddenColumns = function() { + var included = new Set(this.layout.getAllLeafIds().map(function(leafId) { + var f = this.recordLayout.getField(leafId); + return f.isNewField ? f.colRef : f.colRef.peek(); + }, this)); + return this.recordLayout.viewSection.table().columns().all().filter(function(col) { + return !included.has(col.getRowId()) && !col.isHiddenCol(); + }); +}; + +RecordLayoutEditor.prototype._addField = function(leafId) { + var newBox = this.layout.buildLayoutBox({ leaf: leafId }); + var rows = this.layout.rootBox().childBoxes.peek(); + if (rows.length >= 1 && _.last(rows).isLeaf()) { + // Add a new child to the last row. + _.last(rows).addChild(newBox, true); + } else { + // Add a new row. + this.layout.rootBox().addChild(newBox, true); + } +}; + +RecordLayoutEditor.prototype.buildEditorDom = function() { + const addNewField = () => { this._addField(':New_Field:'); }; + const showField = (col) => { + // Use setTimeout, since showing a field synchronously removes it from the list, which would + // prevent the menu from closing if we don't let the event to run its course. + setTimeout(() => this._addField(col.getRowId() + ':' + col.label()), 0); + }; + + return cssControls( + basicButton('Add Field', cssCollapseIcon('Collapse'), + menu((ctl) => [ + menuItem(() => addNewField(), 'Create New Field'), + dom.maybe((use) => use(this._hiddenColumns).length > 0, + () => menuDivider()), + dom.forEach(this._hiddenColumns, (col) => + menuItem(() => showField(col), `Show field ${col.label()}`) + ), + testId('edit-layout-add-menu'), + ]), + ), + + dom('div.flexauto', {style: 'margin-left: 8px'}), + this.buildFinishButtons(), + testId('edit-layout-controls'), + ); +}; + +RecordLayoutEditor.prototype.buildFinishButtons = function() { + return [ + primaryButton('Save Layout', + dom.on('click', () => commands.allCommands.accept.run()), + ), + basicButton('Cancel', + dom.on('click', () => commands.allCommands.cancel.run()), + {style: 'margin-left: 8px'}, + ), + ]; +} + +RecordLayoutEditor.prototype.buildLeafDom = function() { + return dom('div.layout_grabbable.g_record_layout_editing', + dom('div.g_record_delete_field.glyphicon.glyphicon-remove', + dom.on('mousedown', (ev) => ev.stopPropagation()), + dom.on('click', (ev, elem) => { + ev.preventDefault(); + ev.stopPropagation(); + this.layoutEditor.removeContainingBox(elem); + }) + ) + ); +}; + +const cssControls = styled('div', ` + display: flex; + align-items: flex-start; + + & > .${cssButton.className} { + white-space: nowrap; + overflow: hidden; + } +`); + +const cssCollapseIcon = styled(icon, ` + margin: -3px -2px -2px 2px; +`); + +module.exports = RecordLayoutEditor; diff --git a/app/client/components/RefSelect.js b/app/client/components/RefSelect.js new file mode 100644 index 00000000..5865f3a9 --- /dev/null +++ b/app/client/components/RefSelect.js @@ -0,0 +1,226 @@ +var _ = require('underscore'); +var ko = require('knockout'); +var Promise = require('bluebird'); +var koArray = require('../lib/koArray'); +var dispose = require('../lib/dispose'); +var tableUtil = require('../lib/tableUtil'); +var gutil = require('app/common/gutil'); +const {colors, testId} = require('app/client/ui2018/cssVars'); +const {cssFieldEntry, cssFieldLabel} = require('app/client/ui/VisibleFieldsConfig'); +const {dom, fromKo, styled} = require('grainjs'); +const {icon} = require('app/client/ui2018/icons'); +const {menu, menuItem, menuText} = require('app/client/ui2018/menus'); + +/** + * Builder for the reference display multiselect. + */ +function RefSelect(fieldConfigTab) { + this.docModel = fieldConfigTab.gristDoc.docModel; + this.origColumn = fieldConfigTab.origColumn; + this.colId = fieldConfigTab.colId; + this.isForeignRefCol = fieldConfigTab.isForeignRefCol; + + // Computed for the current fieldBuilder's field, if it exists. + this.fieldObs = this.autoDispose(ko.computed(() => { + let builder = fieldConfigTab.fieldBuilder(); + return builder ? builder.field : null; + })); + + // List of valid cols in the currently referenced table. + this._validCols = this.autoDispose(ko.computed(() => { + var refTable = this.origColumn.refTable(); + if (refTable) { + return refTable.columns().all().filter(col => !col.isHiddenCol() && + !gutil.startsWith(col.type(), 'Ref:')); + } + return []; + })); + + // Returns the array of columns added to the multiselect. Used as a helper to create a synced KoArray. + var _addedObs = this.autoDispose(ko.computed(() => { + return this.isForeignRefCol() && this.fieldObs() ? + this._getReferencedCols().map(c => ({ label: c.label(), value: c.colId() })) : []; + })); + + // KoArray of columns displaying data from the referenced table in the current section. + this._added = this.autoDispose(koArray.syncedKoArray(_addedObs)); + + // Set of added colIds. + this._addedSet = this.autoDispose(ko.computed(() => new Set(this._added.all().map(item => item.value)))); +} +dispose.makeDisposable(RefSelect); + + +/** + * Builds the multiselect dom to select columns to added to the table to show data from the + * referenced table. + */ +RefSelect.prototype.buildDom = function() { + return cssFieldList( + testId('ref-select'), + dom.forEach(fromKo(this._added), (col) => + cssFieldEntry( + cssFieldLabel(dom.text(col.label)), + cssRemoveIcon('Remove', + dom.on('click', () => this._removeFormulaField(col)), + testId('ref-select-remove'), + ), + testId('ref-select-item'), + ) + ), + cssAddLink(cssAddIcon('Plus'), 'Add Column', + menu(() => [ + ...this._validCols.peek() + .filter((col) => !this._addedSet.peek().has(col.colId.peek())) + .map((col) => + menuItem(() => this._addFormulaField({label: col.label(), value: col.colId()}), + col.label.peek()) + ), + cssEmptyMenuText("No columns to add"), + testId('ref-select-menu'), + ]), + testId('ref-select-add'), + ), + ); +}; + +const cssFieldList = styled('div', ` + display: flex; + flex-direction: column; + width: 100%; + + & > .${cssFieldEntry.className} { + margin: 2px 0; + } +`); + +const cssEmptyMenuText = styled(menuText, ` + font-size: inherit; + &:not(:first-child) { + display: none; + } +`); + +const cssAddLink = styled('div', ` + display: flex; + cursor: pointer; + color: ${colors.lightGreen}; + --icon-color: ${colors.lightGreen}; + + &:not(:first-child) { + margin-top: 8px; + } + &:hover, &:focus, &:active { + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; + } +`); + +const cssAddIcon = styled(icon, ` + margin-right: 4px; +`); + +const cssRemoveIcon = styled(icon, ` + display: none; + cursor: pointer; + flex: none; + margin-left: 8px; + .${cssFieldEntry.className}:hover & { + display: block; + } +`); + +/** + * Adds the column item to the multiselect. If the visibleCol is 'id', sets the visibleCol. + * Otherwise, adds a field which refers to the column to the table. If a column with the + * necessary formula exists, only adds a field to this section, otherwise adds the necessary + * column and field. + */ +RefSelect.prototype._addFormulaField = function(item) { + var field = this.fieldObs(); + var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData; + // Check if column already exists in the table + var cols = this.origColumn.table().columns().all(); + var colMatch = cols.find(c => c.formula() === `$${this.colId()}.${item.value}` && !c.isHiddenCol()); + // Get field position, so that the new field is inserted just after the current field. + var fields = field.viewSection().viewFields(); + var index = fields.all() + .sort((a, b) => a.parentPos() > b.parentPos() ? a : b) + .findIndex(f => f.getRowId() === field.getRowId()); + var pos = tableUtil.fieldInsertPositions(fields, index + 1)[0]; + var colAction; + if (colMatch) { + // If column exists, use it. + colAction = Promise.resolve({ colRef: colMatch.getRowId(), colId: colMatch.colId() }); + } else { + // If column doesn't exist, add it (without fields). + colAction = tableData.sendTableAction(['AddHiddenColumn', `${this.colId()}_${item.value}`, { + type: 'Any', + isFormula: true, + formula: `$${this.colId()}.${item.value}`, + _position: pos + }]); + } + return colAction.then(colInfo => { + // Add field to the current section. + var fieldInfo = { + colRef: colInfo.colRef, + parentId: field.viewSection().getRowId(), + parentPos: pos + }; + return this.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); + }); +}; + +/** + * Removes the column item from the multiselect. If the item is the visibleCol, clears to show + * row id. Otherwise, removes all fields which refer to the column from the table. + */ +RefSelect.prototype._removeFormulaField = function(item) { + var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData; + // Iterate through all display fields in the current section. + this._getReferrerFields(item.value).forEach(refField => { + var sectionId = this.fieldObs().viewSection().getRowId(); + if (_.any(refField.column().viewFields().all(), field => field.parentId() !== sectionId)) { + // The col has fields in other sections, remove only the fields in this section. + this.docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]); + } else { + // The col is only displayed in this section, remove the column. + tableData.sendTableAction(['RemoveColumn', refField.column().colId()]); + } + }); +}; + +/** + * Returns a list of fields in the current section whose formulas refer to 'colId' in the table this + * reference column refers to. + */ +RefSelect.prototype._getReferrerFields = function(colId) { + var re = new RegExp("^\\$" + this.colId() + "\\." + colId + "$"); + return this.fieldObs().viewSection().viewFields().all() + .filter(field => re.exec(field.column().formula())); +}; + +/** + * Returns a non-repeating list of columns in the referenced table referred to by fields in + * the current section. + */ +RefSelect.prototype._getReferencedCols = function() { + var matchesSet = this._getFormulaMatchSet(); + return this._validCols().filter(c => matchesSet.has(c.colId())); +}; + +/** + * Helper function for getReferencedCols. Iterates through fields in + * the current section, returning a set of colIds which those fields' formulas refer to. + */ +RefSelect.prototype._getFormulaMatchSet = function() { + var fields = this.fieldObs().viewSection().viewFields().all(); + var re = new RegExp("^\\$" + this.colId() + "\\.(\\w+)$"); + return new Set(fields.map(field => { + var found = re.exec(field.column().formula()); + return found ? found[1] : null; + })); +}; + +module.exports = RefSelect; diff --git a/app/client/components/SearchBar.css b/app/client/components/SearchBar.css new file mode 100644 index 00000000..d163d0e7 --- /dev/null +++ b/app/client/components/SearchBar.css @@ -0,0 +1,73 @@ +.searchbar-box.grist-navbar-pfx.part-toolbar-group__item { + display: flex; + width: 15rem; + padding: 0; + color: grey; +} + +.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:focus-within { + box-shadow: 0 0 3px 2px var(--grist-color-cursor); + color: black; +} + +.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:hover { + /* undo the effect of hover in .part-toolbar-group__item */ + background-color: var(--color-navbar-btn-bg); +} + +.searchbar-button.grist-navbar-pfx.part-toolbar-group__item { + padding: 0 3px; +} + +.searchbar-icon { + flex: none; + font-size: 1.2rem; + color: grey; + margin: 0 2px 0 4px; + top: 2px; + line-height: inherit; +} + +.searchbar-icon-indicator { + animation: searchbar_flip 1s ease-in-out infinite; +} + +.searchbar-input { + display: block; + border: none; + outline: none; + background-color: transparent; + height: 22px; + min-width: 0; +} + +.searchbar-buttons { + flex: none; + align-self: center; + margin: 0 2px 0 0 !important; +} + +.searchbar-buttons > .kf_button { + height: 1.6rem; + padding: 0.3rem 0.6rem; +} + +.searchbar-buttons > .disabled { + opacity: 0.5; +} + +@keyframes searchbar_flip { + 0% { transform: scaleX(1); } + 50% { transform: scaleX(-1); } + 100% { transform: scaleX(1); } +} + +/* applies to the cursor element, added and quickly removed to trigger a highlight animation */ +.selected_cursor { + transition: background-color 500ms linear; +} + +.search-match { + transition: none; + background-color: rgba(0, 255, 0, 0.4); +} diff --git a/app/client/components/SearchBar.ts b/app/client/components/SearchBar.ts new file mode 100644 index 00000000..e9c6905f --- /dev/null +++ b/app/client/components/SearchBar.ts @@ -0,0 +1,375 @@ +// tslint:disable:no-console +// TODO: This file should be removed once the old search UI is phased out. + +import {createGroup} from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; +import * as dom from 'app/client/lib/dom'; +import * as kd from 'app/client/lib/koDom'; +import * as kf from 'app/client/lib/koForm'; +import {delay} from 'app/common/delay'; +import {waitObs} from 'app/common/gutil'; +import {TableData} from 'app/common/TableData'; +import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; +import * as ko from 'knockout'; +import debounce = require('lodash/debounce'); + + +/** + * Creates a search box for the toolbar. Returns a value suitable for NavBar.makeToolbarGroup(). + */ +export function makeSearchToolbarGroup(gristDoc: GristDoc) { + const searcher = new Searcher(gristDoc); + + let input: HTMLInputElement; + + // Active normally. + const commandGroup = createGroup({ + find: () => { input.focus(); }, + findNext: () => { searcher.findNext(); }, // tslint:disable-line:no-floating-promises TODO + findPrev: () => { searcher.findPrev(); }, // tslint:disable-line:no-floating-promises TODO + }, null, true); + + // Return an array of one item (for a toolbar group of a single item). The item is an array of + // arguments that populate the div for this single toolbar group item. + return [[ + kd.toggleClass('searchbar-box', true), + dom('span.searchbar-icon.glyphicon.glyphicon-search', + kd.toggleClass('searchbar-icon-indicator', searcher.isRunning) + ), + input = dom('input.searchbar-input', + {placeholder: 'Search'}, + (elem: HTMLInputElement) => bindChangeOrDelay(elem, value => searcher.findFirst(value), 100), + dom.testId('SearchBar_input'), + dom.autoDispose(commandGroup), + commandGroup.attach(), + + dom.on('focus', () => { input.select(); }), + + // Using a keyboard handler directly because command groups are hard to get to work (because + // the searchbox is created so early that the actions like accept/cancel get overridden). + dom.on('keydown', (e: KeyboardEvent) => { + switch (e.keyCode) { + case 13: searcher.findNext(); break; // tslint:disable-line:no-floating-promises TODO + case 27: input.blur(); break; + } + }) + ), + kf.buttonGroup( + kd.toggleClass('searchbar-buttons', true), + kf.button(() => searcher.findPrev(), '\u2329', kd.toggleClass('disabled', searcher.noMatch)), + kf.button(() => searcher.findNext(), '\u232A', kd.toggleClass('disabled', searcher.noMatch)), + ), + ]]; +} + +// Calls the given callback on 'change' event and within delayMs of any 'input' event. +// TODO: This duplicates part of functionality of koForm.textInput(), so the two could be unified. +function bindChangeOrDelay(input: HTMLInputElement, cb: (value: string) => void, delayMs: number) { + const debounced = debounce((e: Event) => cb(input.value), delayMs); + dom.on(input, 'input', debounced); + dom.on(input, 'change', (e: Event) => { debounced(e); debounced.flush(); }); +} + + +interface SearchPosition { + tabIndex: number; + sectionIndex: number; + rowIndex: number; + fieldIndex: number; +} + + +class Stepper { + public array: ReadonlyArray = []; + public index: number = 0; + + public inRange() { + return this.index >= 0 && this.index < this.array.length; + } + + // Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on + // Promises only when needed. + public next(step: number, nextArrayFunc: () => Promise|void): Promise|void { + this.index += step; + if (!this.inRange()) { + // If index reached the end of the array, take a step at a higher level to get a new array. + // For efficiency, only wait asynchronously if the callback returned a promise. + const p = nextArrayFunc(); + if (p) { + return p.then(() => this.setStart(step)); + } else { + this.setStart(step); + } + } + } + + public setStart(step: number) { + this.index = step > 0 ? 0 : this.array.length - 1; + } + + public get value(): T { return this.array[this.index]; } +} + + +class Searcher { + public isRunning = ko.observable(false); + public noMatch = ko.observable(true); + private _searchRegexp: RegExp; + private _tabStepper = new Stepper(); + private _sectionStepper = new Stepper(); + private _sectionTableData: TableData; + private _rowStepper = new Stepper(); + private _fieldStepper = new Stepper(); + private _fieldFormatters: BaseFormatter[]; + private _startPosition: SearchPosition; + private _tabsSwitched: number = 0; + + constructor(private _gristDoc: GristDoc) {} + + public findFirst(value: string) { + if (!value) { this.noMatch(true); return; } + this._searchRegexp = makeRegexp(value); + const tabs: any[] = this._gristDoc.docModel.allTabs.peek(); + this._tabStepper.array = tabs; + this._tabStepper.index = tabs.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get()); + if (this._tabStepper.index < 0) { this.noMatch(true); return; } + + const view = this._tabStepper.value.view.peek(); + const sections: any[] = view.viewSections().peek(); + this._sectionStepper.array = sections; + this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId()); + if (this._sectionStepper.index < 0) { this.noMatch(true); return; } + + this._initNewSectionShown(); + + // Find the current cursor position in the current section. + const viewInstance = this._sectionStepper.value.viewInstance.peek(); + const pos = viewInstance.cursor.getCursorPos(); + this._rowStepper.index = pos.rowIndex; + this._fieldStepper.index = pos.fieldIndex; + + this._startPosition = this._getCurrentPosition(); + return this._matchNext(1); + } + + public async findNext() { + this._startPosition = this._getCurrentPosition(); + await this._nextField(1); + return this._matchNext(1); + } + + public async findPrev() { + this._startPosition = this._getCurrentPosition(); + await this._nextField(-1); + return this._matchNext(-1); + } + + private async _matchNext(step: number): Promise { + const indicatorTimer = setTimeout(() => this.isRunning(true), 300); + try { + const searchRegexp = this._searchRegexp; + let count = 0; + let lastBreak = Date.now(); + + this._tabsSwitched = 0; + while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) { + // To avoid hogging the CPU for too long, check time periodically, and if we've been running + // for long enough, take a brief break. We choose a 5ms break every 20ms; and only check + // time every 100 iterations, to avoid excessive overhead purely due to time checks. + if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) { + await delay(5); + lastBreak = Date.now(); + + // After other code had a chance to run, it's possible that we are now searching for + // something else, in which case abort this task. + if (this._searchRegexp !== searchRegexp) { + console.log("SearchBar: aborting search since a new one was started"); + return; + } + } + + const p = this._nextField(step); + if (p) { await p; } + + // Detect when we get back to the start position; this is where we break on no match. + if (this._isCurrentPosition(this._startPosition)) { + console.log("SearchBar: reached start position without finding anything"); + this.noMatch(true); + return; + } + + // A fail-safe to prevent certain bugs from causing infinite loops; break also if we stan + // through tabs too many times. + // TODO: test it by disabling the check above. + if (this._tabsSwitched > this._tabStepper.array.length) { + console.log("SearchBar: aborting search due to too many tab switches"); + this.noMatch(true); + return; + } + } + console.log("SearchBar: found a match at %s", JSON.stringify(this._getCurrentPosition())); + this.noMatch(false); + await this._highlight(); + } finally { + clearTimeout(indicatorTimer); + this.isRunning(false); + } + } + + private _getCurrentPosition(): SearchPosition { + // It's important to call _getCurrentPosition() in the visible tab, since other tabs will not + // use the currently visible version of the data (with the same sort and filter). + return { + tabIndex: this._tabStepper.index, + sectionIndex: this._sectionStepper.index, + rowIndex: this._rowStepper.index, + fieldIndex: this._fieldStepper.index, + }; + } + + private _isCurrentPosition(pos: SearchPosition): boolean { + return ( + this._tabStepper.index === pos.tabIndex && + this._sectionStepper.index === pos.sectionIndex && + this._rowStepper.index === pos.rowIndex && + this._fieldStepper.index === pos.fieldIndex + ); + } + + private _nextField(step: number): Promise|void { + return this._fieldStepper.next(step, () => this._nextRow(step)); + // console.log("nextField", this._fieldStepper.index); + } + + private _nextRow(step: number) { + return this._rowStepper.next(step, () => this._nextSection(step)); + // console.log("nextRow", this._rowStepper.index); + } + + private async _nextSection(step: number) { + // Switching sections is rare enough that we don't worry about optimizing away `await` calls. + await this._sectionStepper.next(step, () => this._nextTab(step)); + // console.log("nextSection", this._sectionStepper.index); + await this._initNewSectionAny(); + } + + // TODO There are issues with filtering. A section may have filters applied, and it may be + // auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if + // the tab is not shown, it takes work to apply explicit filters. For linked sections, the + // sensible behavior seems to scan through ALL values, then once a match is found, set the + // cursor that determines the linking to include the matched row. And even that may not always + // be possible. So this is an open question. + + private _initNewSectionCommon() { + const section = this._sectionStepper.value; + const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek()); + this._sectionTableData = tableModel.tableData; + + this._fieldStepper.array = section.viewFields().peek(); + this._fieldFormatters = this._fieldStepper.array.map( + f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson())); + return tableModel; + } + + private _initNewSectionShown() { + this._initNewSectionCommon(); + const viewInstance = this._sectionStepper.value.viewInstance.peek(); + this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek(); + } + + private async _initNewSectionAny() { + const tableModel = this._initNewSectionCommon(); + + const viewInstance = this._sectionStepper.value.viewInstance.peek(); + if (viewInstance) { + this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek(); + } else { + // If we are searching through another tab (not currently loaded), we will NOT have a + // viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the + // _loadSection() method will load the tab and we'll repeat the search with a viewInstance. + await tableModel.fetch(); + this._rowStepper.array = this._sectionTableData.getRowIds(); + } + } + + private async _nextTab(step: number) { + await this._tabStepper.next(step, () => undefined); + this._tabsSwitched++; + // console.log("nextTab", this._tabStepper.index); + + const view = this._tabStepper.value.view.peek(); + this._sectionStepper.array = view.viewSections().peek(); + } + + private _matches(): boolean { + if (this._tabStepper.index < 0 || this._sectionStepper.index < 0 || + this._rowStepper.index < 0 || this._fieldStepper.index < 0) { + console.warn("match outside"); + return false; + } + const field = this._fieldStepper.value; + const formatter = this._fieldFormatters[this._fieldStepper.index]; + const rowId = this._rowStepper.value; + const displayCol = field.displayColModel.peek(); + + const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek()); + + // TODO: Note that formatting dates is now the bulk of the performance cost. + const text = formatter.format(value); + return this._searchRegexp.test(text); + } + + private async _loadSection(step: number): Promise { + // If we found a match in a section for which we don't have a valid BaseView instance, we need + // to load the BaseView and start searching the section again, since the match we found does + // not take into account sort or filters. So we switch to the right tab, wait for the + // viewInstance to be created, reset the section info, and return true to continue searching. + const section = this._sectionStepper.value; + if (!section.viewInstance.peek()) { + const view = this._tabStepper.value.view.peek(); + await this._gristDoc.openDocPage(view.getRowId()); + console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId()); + const viewInstance: any = await waitObs(section.viewInstance); + await viewInstance.getLoadingDonePromise(); + this._initNewSectionShown(); + this._rowStepper.setStart(step); + this._fieldStepper.setStart(step); + console.log("SearchBar: loaded view %s section %s", view.getRowId(), section.getRowId()); + return true; + } + return false; + } + + // Highlights the cell at the current position. + private async _highlight() { + const view = this._tabStepper.value.view.peek(); + await this._gristDoc.openDocPage(view.getRowId()); + + const section = this._sectionStepper.value; + view.activeSectionId(section.getRowId()); + + // We may need to wait for the BaseView instance to load. + const viewInstance = await waitObs(section.viewInstance); + await viewInstance.getLoadingDonePromise(); + viewInstance.setCursorPos({ + rowIndex: this._rowStepper.index, + fieldIndex: this._fieldStepper.index, + }); + + // Highlight the selected cursor, after giving it a chance to update. We find the cursor in + // this ad-hoc way rather than use observables, to avoid the overhead of *every* cell + // depending on an additional observable. + await delay(0); + const cursor = viewInstance.viewPane.querySelector('.selected_cursor'); + if (cursor) { + cursor.classList.add('search-match'); + setTimeout(() => cursor.classList.remove('search-match'), 20); + } + } +} + +function makeRegexp(value: string) { + // From https://stackoverflow.com/a/3561711/328565 + const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + return new RegExp(escaped, 'i'); +} diff --git a/app/client/components/Selector.js b/app/client/components/Selector.js new file mode 100644 index 00000000..8cc7745b --- /dev/null +++ b/app/client/components/Selector.js @@ -0,0 +1,278 @@ +/** + * Selector takes care of attaching callbacks to the relevant mouse events on the given view. + * Selection and dragging/dropping consists of 3 phases: mouse down -> mouse move -> mouse up + * The Selector class is purposefully lightweight because different views might have + * different select/drag/drop behavior. Most of the work is done in the callbacks + * provided to the Selector class. + * + * Usage: + Selectors are instantiated with a view. + @param{view}: The view containing the selectable/draggable elements + * Views must also supply the Selector class with mousedown/mousemove/mouseup callbacks and + * the associated element's that listen for the mouse events. + * through registerMouseHandlers. + */ + +/* globals document */ + +var ko = require('knockout'); +var _ = require('underscore'); +var dispose = require('../lib/dispose'); +var gutil = require('app/common/gutil'); + +var ROW = 'row'; +var COL = 'col'; +var CELL = 'cell'; +var NONE = ''; +var SELECT = 'select'; +var DRAG = 'drag'; + +exports.ROW = ROW; +exports.COL = COL; +exports.CELL = CELL; +exports.NONE = NONE; + +/** + * @param {Object} view + * @param {Object} opt + * @param {function} opt.isDisabled - Is this selector disabled? Allows caller to specify + * conditions for temporarily disabling capturing of mouse events. + */ +function Selector(view, opt) { + this.view = view; + // TODO: There should be a better way to ensure that select/drag doesnt happen when clicking + // on these things. Also, these classes should not be in the generic Selector class. + // TODO: get rid of the Selector class entirely and make this a Cell/GridSelector class specifically + // for GridView(and its derived views). + this.exemptClasses = [ + 'glyphicon-pencil', + 'ui-resizable-handle', + 'dropdown-toggle', + ]; + + opt = opt || {}; + + this.isDisabled = opt.isDisabled || _.constant(false); +} + +/** + * Register mouse callbacks to various sources. + * @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions + * for selecting and dragging, along with with the source string name and target element + * string name to which the mouse events must listen on. + * @param {string} handlerName - string name of the kind of element that the mouse callbacks + * are acting on. + * handlerName is used to deduce what kind of element is triggering the mouse callbacks + * The alternative is to look at triggering DOM element's css classes which is more hacky. + */ +Selector.prototype.registerMouseHandlers = function(callbacks, handlerName) { + this.setCallbackDefaults(callbacks); + var self = this; + + this.view.onEvent(callbacks.mousedown.source, 'mousedown', callbacks.mousedown.elemName, + function(elem, event) { + if (self.isExemptMouseTarget(event) || event.button !== 0 || self.isDisabled()) { + return true; // Do nothing if the mouse event if exempt or not a left click + } + + if (!self.isSelected(elem, handlerName) && !callbacks.disableSelect()) { + self.applyCallbacks(SELECT, callbacks, elem, event); + } else if (!callbacks.disableDrag()) { + self.applyCallbacks(DRAG, callbacks, elem, event); + } + }); + +}; + +Selector.prototype.isExemptMouseTarget = function(event) { + var cl = event.target.classList; + return _.some(this.exemptClasses, cl.contains.bind(cl)); +}; + +Selector.prototype.setCallbackDefaults = function(callbacks) { + _.defaults(callbacks, {'mousedown': {}, 'mousemove': {}, 'mouseup': {}, + 'disableDrag': _.constant(false), 'disableSelect': _.constant(false)} + ); + _.defaults(callbacks.mousedown, {'select': _.noop, 'drag': _.noop, 'elemName': null, + 'source': null}); + _.defaults(callbacks.mousemove, {'select': _.noop, 'drag': _.noop, 'elemName': null, + 'source': document}); + _.defaults(callbacks.mouseup, {'select': _.noop, 'drag': _.noop, 'elemName': null, + 'source': document}); +}; + +/** + * Applies the drag or select callback for mousedown and then registers + * the appropriate mousemove and mouseup callbacks. We only register mousemove/mouseup + * after seeing a mousedown event so that we don't have to constantly listen for + * mousemove/mouseup. + * @param {String} dragOrSelect - string that is either 'drag' or 'select' which denotes + * which mouse methods to apply on mouse events. + * @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions + * for selecting and dragging, along with with the source string name and target element + * string name to which the mouse events must listen on. + */ +Selector.prototype.applyCallbacks = function(dragOrSelect, callbacks, mouseDownElem, mouseDownEvent) { + console.assert(dragOrSelect === DRAG || dragOrSelect === SELECT); + var self = this; + + callbacks.mousedown[dragOrSelect].call(this.view, mouseDownElem, mouseDownEvent); + this.view.onEvent(callbacks.mousemove.source, 'mousemove', function(elem, event) { + callbacks.mousemove[dragOrSelect].call(self.view, elem, event); + }); + + this.view.onEvent(callbacks.mouseup.source, 'mouseup', function(elem, event) { + callbacks.mouseup[dragOrSelect].call(self.view, elem, event); + self.view.clearEvent(callbacks.mousemove.source, 'mousemove'); + self.view.clearEvent(callbacks.mouseup.source, 'mouseup'); + if (dragOrSelect === DRAG) self.currentDragType(NONE); + }); +}; + +// =========================================================================== +// CELL SELECTOR + +function CellSelector(view, opt) { + Selector.call(this, view, opt); + + // row or col.start denotes the anchor/initial index of the select range. + // start is not necessarily smaller than end. + // IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5 + this.row = { + start: ko.observable(0), + end: ko.observable(0), + linePos: ko.observable('0px'), + dropIndex: ko.observable(-1), + }; + this.col = { + start: ko.observable(0), + end: ko.observable(0), + linePos: ko.observable('0px'), + dropIndex: ko.observable(-1), + }; + this.currentSelectType = ko.observable(NONE); + this.currentDragType = ko.observable(NONE); + + this.autoDispose(this.view.cursor.rowIndex.subscribeInit(function(rowIndex) { + this.setToCursor(); + }, this)); + this.autoDispose(this.view.cursor.fieldIndex.subscribeInit(function(colIndex) { + this.setToCursor(); + }, this)); +} + +dispose.makeDisposable(CellSelector); +_.extend(CellSelector.prototype, Selector.prototype); + +CellSelector.prototype.setToCursor = function(elemType) { + // Must check that the view contains cursor.rowIndex/cursor.fieldIndex + // in case it has changed. + if (this.view.cursor.rowIndex) { + this.row.start(this.view.cursor.rowIndex()); + this.row.end(this.view.cursor.rowIndex()); + } + if (this.view.cursor.fieldIndex) { + this.col.start(this.view.cursor.fieldIndex()); + this.col.end(this.view.cursor.fieldIndex()); + } + this.currentSelectType(elemType || NONE); +}; + +CellSelector.prototype.containsCell = function(rowIndex, colIndex) { + return this.containsCol(colIndex) && this.containsRow(rowIndex); +}; + +CellSelector.prototype.containsRow = function(rowIndex) { + return gutil.between(rowIndex, this.row.start(), this.row.end()); +}; + +CellSelector.prototype.containsCol = function(colIndex) { + return gutil.between(colIndex, this.col.start(), this.col.end()); +}; + +CellSelector.prototype.isSelected = function(elem, handlerName) { + if (handlerName !== this.currentSelectType()) return false; + + // TODO: this only works with view: GridView. + // But it seems like we only ever use selectors with gridview anyway + let row = this.view.domToRowModel(elem, handlerName); + let col = this.view.domToColModel(elem, handlerName); + switch (handlerName) { + case ROW: + return this.containsRow(row._index()); + case COL: + return this.containsCol(col._index()); + case CELL: + return this.containsCell(row._index(), col._index()); + default: + console.error('Given element is not a row, cell or column'); + return false; + } +}; + +CellSelector.prototype.isRowSelected = function(rowIndex) { + return this.isCurrentSelectType(COL) || this.containsRow(rowIndex); +}; + +CellSelector.prototype.isColSelected = function(colIndex) { + return this.isCurrentSelectType(ROW) || this.containsCol(colIndex); +}; + +CellSelector.prototype.isCellSelected = function(rowIndex, colIndex) { + return this.isColSelected(colIndex) && this.isRowSelected(rowIndex); +}; + +CellSelector.prototype.onlyCellSelected = function(rowIndex, colIndex) { + return (this.row.start() === rowIndex && this.row.end() === rowIndex) && + (this.col.start() === colIndex && this.col.end() === colIndex); +}; + +CellSelector.prototype.isCurrentSelectType = function(elemType) { + return this._isCurrentType(this.currentSelectType(), elemType); +}; + +CellSelector.prototype.isCurrentDragType = function(elemType) { + return this._isCurrentType(this.currentDragType(), elemType); +}; + +CellSelector.prototype._isCurrentType = function(currentType, elemType) { + console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1); + return currentType === elemType; +}; + +CellSelector.prototype.colLower = function() { + return Math.min(this.col.start(), this.col.end()); +}; + +CellSelector.prototype.colUpper = function() { + return Math.max(this.col.start(), this.col.end()); +}; + +CellSelector.prototype.rowLower = function() { + return Math.min(this.row.start(), this.row.end()); +}; + +CellSelector.prototype.rowUpper = function() { + return Math.max(this.row.start(), this.row.end()); +}; + +CellSelector.prototype.colCount = function() { + return this.colUpper() - this.colLower() + 1; +}; + +CellSelector.prototype.rowCount = function() { + return this.rowUpper() - this.rowLower() + 1; +}; + +CellSelector.prototype.selectArea = function(rowStartIdx,colStartIdx,rowEndIdx,colEndIdx) { + this.row.start(rowStartIdx); + this.col.start(colStartIdx); + this.row.end(rowEndIdx); + this.col.end(colEndIdx); + // Only select the area if it's not a single cell + if (this.colCount() > 1 || this.rowCount() > 1) { + this.currentSelectType(CELL); + } +}; + +exports.CellSelector = CellSelector; diff --git a/app/client/components/SummaryConfig.js b/app/client/components/SummaryConfig.js new file mode 100644 index 00000000..60aa7f5f --- /dev/null +++ b/app/client/components/SummaryConfig.js @@ -0,0 +1,129 @@ +const ko = require('knockout'); +const dispose = require('../lib/dispose'); +const dom = require('../lib/dom'); +const kd = require('../lib/koDom'); +const kf = require('../lib/koForm'); +const koArray = require('../lib/koArray'); +const multiselect = require('../lib/multiselect'); +const modelUtil = require('../models/modelUtil'); +const gutil = require('app/common/gutil'); + + +/** + * Maintains the part of side-pane configuration responsible for summary tables. In particular, it + * allows the user to see and change group-by columns. + * @param {GristDoc} options.gristDoc: the GristDoc instance. + * @param {observable} options.section: the observable for the ViewSection RowModel being configured. + */ +function SummaryConfig(options) { + this.gristDoc = options.gristDoc; + this.section = options.section; + + // Whether or not this is a summary section at all. + this.isSummarySection = this.autoDispose(ko.computed(() => + Boolean(this.section().table().summarySourceTable()))); + + // Observable for the RowModel for the source table for this summary table. + this._summarySourceTable = this.autoDispose(ko.computed(() => + this.section().table().summarySource() + )); + + // Observable for the array of colRefs for the source group-by columns. It may be saved to sync + // to the server, or reverted. + this._groupByCols = this.autoDispose(modelUtil.customComputed({ + read: () => ( + this.section().viewFields().all().map(f => f.column().summarySourceCol()) + .concat( + // If there are hidden group-by columns, list those as well. + this.section().hiddenColumns().map(col => col.summarySourceCol()) + ) + .filter(scol => scol) + ), + save: colRefs => this.gristDoc.docData.sendAction( + ["UpdateSummaryViewSection", this.section().getRowId(), colRefs] + ) + })); + + // Observable for the same set of colRefs as in this._groupByCols, for faster lookups. + this._groupBySourceColSet = this.autoDispose(ko.computed(() => new Set(this._groupByCols()))); + + // KoArray for the RowModels for the source group-by columns. + this._groupByItems = this.autoDispose(koArray.syncedKoArray(this._groupByCols, + colRef => this.gristDoc.docModel.columns.getRowModel(colRef))); +} +dispose.makeDisposable(SummaryConfig); + + +/** + * Helper that implements the auto-complete search of columns available for group-by. + * Calls response() with a list of {label, value} objects, where 'label' is the colId, and 'value' + * is the rowId. + */ +SummaryConfig.prototype._groupBySearch = function(request, response) { + response( + this._summarySourceTable().columns().peek().filter(c => { + return gutil.startsWith(c.label().toLowerCase(), request.term.toLowerCase()) && + !this._groupBySourceColSet().has(c.getRowId()) && !c.isHiddenCol(); + }) + .map(c => ({label: c.label(), value: c.getRowId()})) + ); +}; + + +/** + * Saves this summary table as an independent table. + */ +SummaryConfig.prototype._saveAsTable = function() { + return this.gristDoc.docData.sendAction( + ["DetachSummaryViewSection", this.section().getRowId()]); +}; + + +/** + * Build the DOM for summary table config. + */ +SummaryConfig.prototype.buildSummaryConfigDom = function() { + return dom('div', + dom.testId('SummaryConfig'), + dom('div.multiselect-hint', 'Select columns to group by.'), + multiselect(this._groupBySearch.bind(this), this._groupByItems, col => { + return dom('div.multiselect-label', kd.text(col.label)); + }, { + // Shows up when no group-by columns are selected + hint: "Showing totals.", + + add: item => this._groupByCols.modifyAssign(colRefs => + colRefs.push(item.value)), + + remove: col => this._groupByCols.modifyAssign(colRefs => + gutil.arrayRemove(colRefs, col.getRowId())), + + reorder: (col, nextCol) => this._groupByCols.modifyAssign(colRefs => { + gutil.arrayRemove(colRefs, col.getRowId()); + gutil.arrayInsertBefore(colRefs, col.getRowId(), nextCol ? nextCol.getRowId() : null); + }), + }), + + kf.row( + 2, kf.buttonGroup( + kf.button(() => this._groupByCols.revert(), + kd.toggleClass('disabled', this._groupByCols.isSaved), + 'Cancel' + ), + kf.button(() => this._groupByCols.save(), + kd.toggleClass('disabled', this._groupByCols.isSaved), + 'Apply' + ) + ), + 1, kf.buttonGroup( + kf.button(() => this._saveAsTable(), + { title: 'Save summary as a separate table' }, + 'Detach' + ) + ) + ) + ); +}; + + +module.exports = SummaryConfig; diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts new file mode 100644 index 00000000..57fbe528 --- /dev/null +++ b/app/client/components/TypeConversion.ts @@ -0,0 +1,196 @@ +/** + * This module contains various logic for converting columns between types. It is used from + * TypeTransform.js. + */ +// tslint:disable:no-console + +import {DocModel} from 'app/client/models/DocModel'; +import {ColumnRec} from 'app/client/models/entities/ColumnRec'; +import * as UserType from 'app/client/widgets/UserType'; +import * as gristTypes from 'app/common/gristTypes'; +import * as gutil from 'app/common/gutil'; +import {TableData} from 'app/common/TableData'; + +export interface ColInfo { + type: string; + isFormula: boolean; + formula: string; + visibleCol: number; + widgetOptions?: string; +} + +/** + * Returns the suggested full type for `column` given a desired pure type to convert it to. + * Specifically, a pure type of "DateTime" returns a full type of "DateTime:{timezone}", and "Ref" + * returns a full type of "Ref:{TableId}". A `type` that's already complete is returned unchanged. + */ +export function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocModel) { + switch (type) { + case "Ref": { + const refTableId = getRefTableIdFromData(docModel, column) || column.table().primaryTableId(); + return 'Ref:' + refTableId; + } + case "DateTime": + return 'DateTime:' + docModel.docInfo.getRowModel(1).timezone(); + default: + return type; + } +} + +/** + * Looks through the data of the given column to find the first value of the form + * [R, , ] (a Reference value returned from a formula), and returns the tableId + * from that. + */ +function getRefTableIdFromData(docModel: DocModel, column: ColumnRec): string|null { + const tableData = docModel.docData.getTable(column.table().tableId()); + const columnData = tableData && tableData.getColValues(column.colId()); + if (columnData) { + for (const value of columnData) { + if (gristTypes.isObject(value) && value[0] === 'R') { + return value[1]; + } else if (typeof value === 'string') { + // If it looks like a formatted Ref value (e.g. "Table1[123]"), and the tableId is valid, + // use it. (This helps if a Ref-returning formula column got converted to Text first.) + const match = value.match(/^(\w+)\[\d+\]/); + if (match && docModel.docData.getTable(match[1])) { + return match[1]; + } + } + } + } + return null; +} + + +// Given info about the original column, and the type of the new one, returns a promise for the +// ColInfo to use for the transform column. Note that isFormula will be set to true, and formula +// will be set to the expression to compute the new values from the old ones. +// @param toTypeMaybeFull: Type to convert the column to, either full ('Ref:Foo') or pure ('Ref'). +export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRec, origDisplayCol: ColumnRec, + toTypeMaybeFull: string): Promise { + const toType = gristTypes.extractTypeFromColType(toTypeMaybeFull); + const tableData: TableData = docModel.docData.getTable(origCol.table().tableId())!; + let widgetOptions: any = null; + + const colInfo: ColInfo = { + type: addColTypeSuffix(toTypeMaybeFull, origCol, docModel), + isFormula: true, + visibleCol: 0, + formula: "", // Will be filled in at the end. + }; + + switch (toType) { + case 'Choice': { + // Set suggested choices. Limit to 100, since too many choices is more likely to cause + // trouble than desired behavior. For many choices, recommend using a Ref to helper table. + const columnData = tableData.getDistinctValues(origCol.colId(), 100); + if (columnData) { + columnData.delete(""); + widgetOptions = {choices: Array.from(columnData, String)}; + } + break; + } + case 'Ref': { + // Set suggested destination table and visible column. + // Null if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen). + const optTableId = gutil.removePrefix(toTypeMaybeFull, "Ref:")!; + + // Finds a reference suggestion column and sets it as the current reference value. + const columnData = tableData.getDistinctValues(origDisplayCol.colId(), 100); + if (!columnData) { break; } + columnData.delete(gristTypes.getDefaultForType(origCol.type())); + + // 'findColFromValues' function requires an array since it sends the values to the sandbox. + const matches: number[] = await docModel.docData.findColFromValues(Array.from(columnData), 2, optTableId); + const suggestedColRef = matches.find(match => match !== origCol.getRowId()); + if (!suggestedColRef) { break; } + const suggestedCol = docModel.columns.getRowModel(suggestedColRef); + const suggestedTableId = suggestedCol.table().tableId(); + if (optTableId && suggestedTableId !== optTableId) { + console.warn("Inappropriate column received from findColFromValues"); + break; + } + colInfo.type = `Ref:${suggestedTableId}`; + colInfo.visibleCol = suggestedColRef; + break; + } + } + + const newOptions = UserType.mergeOptions(widgetOptions || {}, colInfo.type); + if (widgetOptions) { + colInfo.widgetOptions = JSON.stringify(widgetOptions); + } + colInfo.formula = getDefaultFormula(docModel, origCol, colInfo.type, colInfo.visibleCol, newOptions); + return colInfo; +} + +// Given the transformCol, calls (if needed) a user action to update its displayCol. +export async function setDisplayFormula( + docModel: DocModel, transformCol: ColumnRec, visibleCol?: number +): Promise { + const vcolRef = (visibleCol == null) ? transformCol.visibleCol() : visibleCol; + if (isReferenceCol(transformCol)) { + const vcol = getVisibleColName(docModel, vcolRef); + const tcol = transformCol.colId(); + const displayFormula = (vcolRef === 0 ? '' : `$${tcol}.${vcol}`); + return transformCol.saveDisplayFormula(displayFormula); + } +} + +// Given the original column and info about the new column, returns the formula to use for the +// transform column to do the transformation. +export function getDefaultFormula( + docModel: DocModel, origCol: ColumnRec, newType: string, + newVisibleCol: number, newWidgetOptions: any): string { + + const colId = origCol.colId(); + const oldVisibleColName = isReferenceCol(origCol) ? + getVisibleColName(docModel, origCol.visibleCol()) : undefined; + + const origValFormula = oldVisibleColName ? + // The `str()` below converts AltText to plain text. + `$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` : + `$${colId}`; + const toTypePure: string = gristTypes.extractTypeFromColType(newType); + + // The args are used to construct the call to grist.TYPE.typeConvert(value, [params]). + // Optional parameters depend on the type; see sandbox/grist/usertypes.py + const args: string[] = [origValFormula]; + switch (toTypePure) { + case 'Ref': { + const table = gutil.removePrefix(newType, "Ref:"); + args.push(table || 'None'); + const visibleColName = getVisibleColName(docModel, newVisibleCol); + if (visibleColName) { + args.push(q(visibleColName)); + } + break; + } + case 'Date': { + args.push(q(newWidgetOptions.dateFormat)); + break; + } + case 'DateTime': { + const timezone = gutil.removePrefix(newType, "DateTime:") || ''; + const format = newWidgetOptions.dateFormat + ' ' + newWidgetOptions.timeFormat; + args.push(q(format), q(timezone)); + break; + } + } + return `grist.${gristTypes.getGristType(toTypePure)}.typeConvert(${args.join(', ')})`; +} + +function q(value: string): string { + return "'" + value.replace(/'/g, "\\'") + "'"; +} + +// Returns the name of the visibleCol given its rowId. +function getVisibleColName(docModel: DocModel, visibleColRef: number): string|undefined { + return visibleColRef ? docModel.columns.getRowModel(visibleColRef).colId() : undefined; +} + +// Returns whether the given column model is of type Ref. +function isReferenceCol(colModel: ColumnRec) { + return gristTypes.extractTypeFromColType(colModel.type()) === 'Ref'; +} diff --git a/app/client/components/TypeTransform.ts b/app/client/components/TypeTransform.ts new file mode 100644 index 00000000..13fe9473 --- /dev/null +++ b/app/client/components/TypeTransform.ts @@ -0,0 +1,140 @@ +/** + * TypeTransform extends ColumnTransform, creating the transform dom prompt that is shown when the + * user changes the type of a data column. The purpose is to aid the user in converting data to the new + * type by allowing a formula to be applied prior to conversion. It also allows for program-generated formulas + * to be pre-entered for certain transforms (to Reference / Date) which the user can modify via dropdown menus. + */ + +import * as AceEditor from 'app/client/components/AceEditor'; +import {ColumnTransform} from 'app/client/components/ColumnTransform'; +import {GristDoc} from 'app/client/components/GristDoc'; +import * as TypeConversion from 'app/client/components/TypeConversion'; +import {reportError} from 'app/client/models/errors'; +import * as modelUtil from 'app/client/models/modelUtil'; +import {cssButtonRow} from 'app/client/ui/RightPanel'; +import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; +import {testId} from 'app/client/ui2018/cssVars'; +import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; +import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; +import {ColValues} from 'app/common/DocActions'; +import {Computed, dom, fromKo, Observable} from 'grainjs'; +import isEmpty = require('lodash/isEmpty'); +import pickBy = require('lodash/pickBy'); + +// To simplify diff (avoid rearranging methods to satisfy private/public order). +// tslint:disable:member-ordering + +/** + * Creates an instance of TypeTransform for a single field. Extends ColumnTransform. + */ +export class TypeTransform extends ColumnTransform { + private reviseTypeChange = Observable.create(this, false); + private transformWidget: Computed; + + constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) { + super(gristDoc, fieldBuilder); + + // The display widget of the new transform column. Used to build the transform config menu. + // Only set while transforming. + this.transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => { + return use(this.origColumn.isTransforming) ? widget : null; + }); + } + + /** + * Build the transform menu for a type transform + */ + public buildDom() { + // An observable to disable all buttons before the dom get removed. + const disableButtons = Observable.create(null, false); + + this.reviseTypeChange.set(false); + this.editor = this.autoDispose(AceEditor.create({ observable: this.transformColumn.formula })); + return dom('div', + testId('type-transform-top'), + dom.maybe(this.transformWidget, transformWidget => transformWidget.buildTransformConfigDom()), + dom.maybe(this.reviseTypeChange, () => + dom('div.transform_editor', this.buildEditorDom(), + testId("type-transform-formula") + ) + ), + cssButtonRow( + basicButton(dom.on('click', () => { this.cancel(); disableButtons.set(true); }), + 'Cancel', testId("type-transform-cancel"), + dom.cls('disabled', disableButtons) + ), + dom.domComputed(this.reviseTypeChange, revising => { + if (revising) { + return basicButton(dom.on('click', () => this.editor.writeObservable()), + 'Preview', testId("type-transform-update"), + dom.cls('disabled', (use) => use(disableButtons) || use(this.formulaUpToDate)), + { title: 'Update formula (Shift+Enter)' } + ); + } else { + return basicButton(dom.on('click', () => { this.reviseTypeChange.set(true); }), + 'Revise', testId("type-transform-revise"), + dom.cls('disabled', disableButtons) + ); + } + }), + primaryButton(dom.on('click', () => { this.execute().catch(reportError); disableButtons.set(true); }), + 'Apply', testId("type-transform-apply"), + dom.cls('disabled', disableButtons) + ) + ) + ); + } + + protected async resetToDefaultFormula() { + if (!this.isExecuting()) { + const toType = this.transformColumn.type.peek(); + const formula = TypeConversion.getDefaultFormula(this.gristDoc.docModel, this.origColumn, + toType, this.field.visibleColRef(), this.field.widgetOptionsJson()); + await modelUtil.setSaveValue(this.transformColumn.formula, formula); + } + } + + /** + * Overrides parent method to initialize the transform column with guesses as to the particular + * type and column options. + * @param {String} toType: A pure or complete type for the transformed column. + */ + protected async addTransformColumn(toType: string) { + const docModel = this.gristDoc.docModel; + const colInfo = await TypeConversion.prepTransformColInfo(docModel, this.origColumn, this.origDisplayCol, toType); + const newColInfo = await this._tableData.sendTableAction(['AddColumn', 'gristHelper_Transform', colInfo]); + const tcol = docModel.columns.getRowModel(newColInfo.colRef); + await TypeConversion.setDisplayFormula(docModel, tcol); + return newColInfo.colRef; + } + + /** + * Overrides parent method to subscribe to changes to the transform column. + */ + protected postAddTransformColumn() { + // When a user-initiated change is saved to type or widgetOptions, update the formula. + this.autoDispose(this.transformColumn.type.subscribe(this.resetToDefaultFormula, this, "save")); + this.autoDispose(this.transformColumn.visibleCol.subscribe(this.resetToDefaultFormula, this, "save")); + this.autoDispose(this.field.widgetOptionsJson.subscribe(this.resetToDefaultFormula, this, "save")); + } + + /** + * When a type is changed, again guess appropriate column options. + */ + public async setType(toType: string) { + const docModel = this.gristDoc.docModel; + const colInfo = await TypeConversion.prepTransformColInfo(docModel, this.origColumn, this.origDisplayCol, toType); + // Only update those values which changed, and only if needed. + const tcol = this.transformColumn; + const changedInfo = pickBy(colInfo, (val, key) => + (val !== tcol[key as keyof TypeConversion.ColInfo].peek())); + return Promise.all([ + isEmpty(changedInfo) ? undefined : tcol.updateColValues(changedInfo as ColValues), + TypeConversion.setDisplayFormula(docModel, tcol, changedInfo.visibleCol) + ]); + } + + public finalize() { + return this.execute(); + } +} diff --git a/app/client/components/UndoStack.ts b/app/client/components/UndoStack.ts new file mode 100644 index 00000000..f4524667 --- /dev/null +++ b/app/client/components/UndoStack.ts @@ -0,0 +1,148 @@ +import {CursorPos} from 'app/client/components/Cursor'; +import {GristDoc} from 'app/client/components/GristDoc'; +import * as dispose from 'app/client/lib/dispose'; +import {ActionGroup} from 'app/common/ActionGroup'; +import {PromiseChain} from 'app/common/gutil'; +import {fromKo, Observable} from 'grainjs'; +import * as ko from 'knockout'; + +export interface ActionGroupWithCursorPos extends ActionGroup { + cursorPos?: CursorPos; +} + +// Provides observables indicating disabled state for undo/redo. +export interface IUndoState { + isUndoDisabled: Observable; + isRedoDisabled: Observable; +} + +/** + * Maintains the stack of actions which can be undone and redone, and maintains the + * position in this stack. Undo and redo actions are generated and sent to the server here. + */ +export class UndoStack extends dispose.Disposable { + + public undoDisabledObs: ko.Observable; + public redoDisabledObs: ko.Observable; + private _gristDoc: GristDoc; + private _stack: ActionGroupWithCursorPos[]; + private _pointer: number; + private _linkMap: {[actionNum: number]: ActionGroup}; + + // Chain of promises which send undo actions to the server. This delays the execution of the + // next action until the current one has been received and moved the pointer index. + private _undoChain = new PromiseChain(); + + public create(log: ActionGroup[], options: {gristDoc: GristDoc}) { + this._gristDoc = options.gristDoc; + + // TODO: _stack and _linkMap grow without bound within a single session. + // The top of the stack is stack.length - 1. The pointer points above the most + // recently applied (not undone) action. + this._stack = []; + this._pointer = 0; + + // Map leading from actionNums to the action groups which link to them. + this._linkMap = {}; + + // Observables for when there is nothing to undo/redo. + this.undoDisabledObs = ko.observable(true); + this.redoDisabledObs = ko.observable(true); + + // Set the history nav interface in the DocPageModel to properly enable/disabled undo/redo. + if (this._gristDoc.docPageModel) { + this._gristDoc.docPageModel.undoState.set({ + isUndoDisabled: fromKo(this.undoDisabledObs), + isRedoDisabled: fromKo(this.redoDisabledObs) + }); + } + + // Initialize the stack from the log of recent actions from the server. + log.forEach(ag => { this.pushAction(ag); }); + } + + /** + * Should only be given own actions. Pays attention to actionNum, otherId, linkId, and + * uses those to adjust undo index. + */ + public pushAction(ag: ActionGroup): void { + if (!ag.fromSelf) { + return; + } + const otherIndex = ag.otherId ? + this._stack.findIndex(a => a.actionNum === ag.otherId) : -1; + + if (ag.linkId) { + // Link action. Add the action to the linkMap, but not to any stacks. + this._linkMap[ag.linkId] = ag; + } else if (otherIndex > -1) { + // Undo/redo action from the current session. + this._pointer = ag.isUndo ? otherIndex : otherIndex + 1; + } else { + // Either a normal action from the current session, or an undo/redo which + // applies to a non-recent action. Bury all undone actions. + if (!this.redoDisabledObs()) { + this._stack.splice(this._pointer); + } + // Reset pointer and add to the stack (if not an undo action). + if (!ag.otherId) { + this._stack.push(ag); + } + this._pointer = this._stack.length; + } + this.undoDisabledObs(this._pointer <= 0); + this.redoDisabledObs(this._pointer >= this._stack.length); + } + + // Send an undo action. This should be called when the user presses 'undo'. + public sendUndoAction(): Promise { + return this._undoChain.add(() => this._sendAction(true)); + } + + // Send a redo action. This should be called when the user presses 'redo'. + public sendRedoAction(): Promise { + return this._undoChain.add(() => this._sendAction(false)); + } + + private async _sendAction(isUndo: boolean): Promise { + // Pick the action group to undo or redo. + const ag = this._stack[isUndo ? this._pointer - 1 : this._pointer]; + if (!ag) { return; } + + try { + // Get all actions in the bundle that starts at the current index. Typically, an array with a + // single action group is returned. + const actionGroups = this._findActionBundle(ag); + // When we undo/redo, jump to the place where this action occurred, to bring the user to the + // context where the change was originally made. We jump first immediately to feel more + // responsive, then again when the action is done. The second jump matters more for most + // changes, but the first is the important one when Undoing an AddRecord. + this._gristDoc.moveToCursorPos(ag.cursorPos, ag); + await this._gristDoc.docComm.applyUserActionsById( + actionGroups.map(a => a.actionNum), + actionGroups.map(a => a.actionHash), + isUndo, + { otherId: ag.actionNum }); + this._gristDoc.moveToCursorPos(ag.cursorPos, ag); + } catch (err) { + err.message = `Failed to apply ${isUndo ? 'undo' : 'redo'} action: ${err.message}`; + throw err; + } + } + + /** + * Find all actionGroups in the bundle that starts with the given action group. + */ + private _findActionBundle(ag: ActionGroup) { + const prevNums = new Set(); + const actionGroups = []; + // Follow references through the linkMap adding items to the array bundle. + while (ag && !prevNums.has(ag.actionNum)) { + // Checking that actions are only accessed once prevents an infinite circular loop. + actionGroups.push(ag); + prevNums.add(ag.actionNum); + ag = this._linkMap[ag.actionNum]; + } + return actionGroups; + } +} diff --git a/app/client/components/ValidationPanel.css b/app/client/components/ValidationPanel.css new file mode 100644 index 00000000..7f9a1061 --- /dev/null +++ b/app/client/components/ValidationPanel.css @@ -0,0 +1,29 @@ +.validation { + background-color: rgba(255, 255, 255, .5); + margin: 4px 8px 4px 1px; + padding: 3px 0; +} + +.validation_title { + position: relative; + width: 100%; + padding: 4px 8px; + margin-bottom: 10px; + border-bottom: 1px solid #E6E6E6; +} + +.validation_trash { + cursor: pointer; + color: #AAA; + font-size: 1.1rem; +} + +.validation_trash:hover { + color: black; +} + +.validation_formula { + width: 90%; + margin: 5px auto; + border: 1px solid #DDD; +} diff --git a/app/client/components/ValidationPanel.js b/app/client/components/ValidationPanel.js new file mode 100644 index 00000000..144c54bf --- /dev/null +++ b/app/client/components/ValidationPanel.js @@ -0,0 +1,97 @@ +/* global $ */ +var ko = require('knockout'); +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var kf = require('../lib/koForm'); +var AceEditor = require('./AceEditor'); + +/** + * Document level configuration settings. + * @param {Object} options.gristDoc A reference to the GristDoc object + * @param {Function} docName A knockout observable containing a String + */ +function ValidationPanel(options) { + this.gristDoc = options.gristDoc; + + this.validationsTable = this.gristDoc.docModel.validations; + this.validations = this.autoDispose(this.validationsTable.createAllRowsModel('id')); + + this.docTables = this.autoDispose( + this.gristDoc.docModel.tables.createAllRowsModel('tableId')); + + this.tableChoices = this.autoDispose(this.docTables.map(function(table) { + return { label: table.tableId, value: table.id() }; + })); +} +dispose.makeDisposable(ValidationPanel); + + +ValidationPanel.prototype.onAddRule = function() { + this.validationsTable.sendTableAction(["AddRecord", null, { + tableRef: this.docTables.at(0).id(), + name: "Rule " + (this.validations.peekLength + 1), + formula: "" + }]) + .then(function() { + $('.validation_formula').last().find("input").focus(); + }); +}; + +ValidationPanel.prototype.onDeleteRule = function(rowId) { + this.validationsTable.sendTableAction(["RemoveRecord", rowId]); +}; + +ValidationPanel.prototype.buildDom = function() { + return [ + kf.row( + 1, kf.label('Validations'), + 1, kf.buttonGroup( + kf.button(this.onAddRule.bind(this), 'Add Rule', dom.testId("Validation_addRule")) + ) + ), + dom('div', + dom.testId("Validation_rules"), + kd.foreach(this.validations, validation => { + var editor = AceEditor.create({ observable: validation.formula }); + var editorUpToDate = ko.observable(true); + return dom('div.validation', + dom.autoDispose(editor), + dom('div.validation_title.flexhbox', + dom('div.validation_name', kf.editableLabel(validation.name)), + dom('div.flexitem'), + dom('div.validation_trash.glyphicon.glyphicon-remove', + dom.on('click', this.onDeleteRule.bind(this, validation.id())) + ) + ), + kf.row( + 1, dom('div.glyphicon.glyphicon-tag.config_icon'), + 8, kf.label('Table'), + 9, kf.select(validation.tableRef, this.tableChoices) + ), + dom('div.kf_elem.validation_formula', editor.buildDom(aceObj => { + editor.attachSaveCommand(); + aceObj.on('change', () => { + // Monitor whether the value mismatch is reflected by editorDiff + if ((editor.getValue() === validation.formula()) !== editorUpToDate()) { + editorUpToDate(!editorUpToDate()); + } + }); + aceObj.removeAllListeners('blur'); + })), + kf.row( + 2, '', + 1, kf.buttonGroup( + kf.button(() => editor.writeObservable(), + 'Apply', { title: 'Update formula (Shift+Enter)' }, + kd.toggleClass('disabled', editorUpToDate) + ) + ) + ) + ); + }) + ) + ]; +}; + +module.exports = ValidationPanel; diff --git a/app/client/components/ViewConfigTab.css b/app/client/components/ViewConfigTab.css new file mode 100644 index 00000000..6f45dd8d --- /dev/null +++ b/app/client/components/ViewConfigTab.css @@ -0,0 +1,38 @@ +.view_config_draggable_field { + position: relative; + margin: .2rem .5rem; + padding: .2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.view_config_draggable_field:hover { + background-color: var(--color-list-item-hover); +} + +.view_config_draggable_field > .drag_delete { + float: none; + position: absolute; + top: 0.3rem; + right: 0.2rem; + + background-color: white; + padding: 0.2rem; + border-radius: 1rem; + margin: 0; +} + +.view_config_draggable_field > .drag_delete:hover { + color: black; +} + +.view_config_field_group.kf_collapser { + font-size: inherit; + font-weight: bold; + margin: 1rem .5rem; +} + +.view_config_draggable_field > .kf_draggable_content { + display: inline; +} diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js new file mode 100644 index 00000000..c7077009 --- /dev/null +++ b/app/client/components/ViewConfigTab.js @@ -0,0 +1,759 @@ +var _ = require('underscore'); +var ko = require('knockout'); +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var kf = require('../lib/koForm'); +var koArray = require('../lib/koArray'); +var {showConfirmDialog} = require('./Confirm'); +var SummaryConfig = require('./SummaryConfig'); +var commands = require('./commands'); +var {CustomSectionElement} = require('../lib/CustomSectionElement'); +const {buildChartConfigDom} = require('./ChartView'); +const {Computed, dom: grainjsDom, makeTestId, Observable, styled} = require('grainjs'); +const {VisibleFieldsConfig} = require('app/client/ui/VisibleFieldsConfig'); + +const {addToSort, flipColDirection, parseSortColRefs} = require('app/client/lib/sortUtil'); +const {reorderSortRefs, updatePositions} = require('app/client/lib/sortUtil'); +const {cssIcon, cssRow} = require('app/client/ui/RightPanel'); +const {basicButton, primaryButton} = require('app/client/ui2018/buttons'); +const {colors} = require('app/client/ui2018/cssVars'); +const {cssDragger} = require('app/client/ui2018/draggableList'); +const {menu, menuItem, select} = require('app/client/ui2018/menus'); +const isEqual = require('lodash/isEqual'); +const {cssMenuItem} = require('popweasel'); + +const testId = makeTestId('test-vconfigtab-'); + +/** + * Helper class that combines one ViewSection's data for building dom. + */ +function ViewSectionData(section) { + this.section = section; + + // A koArray reflecting the columns (RowModels) that are not present in the current view. + this.hiddenFields = this.autoDispose(koArray.syncedKoArray(section.hiddenColumns)); +} +dispose.makeDisposable(ViewSectionData); + + +function ViewConfigTab(options) { + var self = this; + this.gristDoc = options.gristDoc; + this.viewModel = options.viewModel; + + // viewModel may point to different views, but viewSectionData is a single koArray reflecting + // the sections of the current view. + this.viewSectionData = this.autoDispose( + koArray.syncedKoArray(this.viewModel.viewSections, function(section) { + return ViewSectionData.create(section); + }) + .setAutoDisposeValues() + ); + + this.activeSectionData = this.autoDispose(ko.computed(function() { + return _.find(self.viewSectionData.all(), function(sectionData) { + return sectionData.section && + sectionData.section.getRowId() === self.viewModel.activeSectionId(); + }) || self.viewSectionData.at(0); + })); + this.isDetail = this.autoDispose(ko.computed(function() { + return ['detail','single'].includes(this.viewModel.activeSection().parentKey()); + }, this)); + this.isChart = this.autoDispose(ko.computed(function() { + return this.viewModel.activeSection().parentKey() === 'chart';}, this)); + this.isGrid = this.autoDispose(ko.computed(function() { + return this.viewModel.activeSection().parentKey() === 'record';}, this)); + this.isCustom = this.autoDispose(ko.computed(function() { + return this.viewModel.activeSection().parentKey() === 'custom';}, this)); + + this._summaryConfig = this.autoDispose(SummaryConfig.create({ + gristDoc: this.gristDoc, + section: this.viewModel.activeSection + })); + + if (!options.skipDomBuild) { + this.gristDoc.addOptionsTab( + 'View', dom('span.glyphicon.glyphicon-credit-card'), + this.buildConfigDomObj(), + { 'category': 'options', 'show': this.activeSectionData } + ); + } +} +dispose.makeDisposable(ViewConfigTab); + + +function getLabelFunc(field) { return field ? field.label() : null; } + +ViewConfigTab.prototype._buildSectionFieldsConfig = function() { + var self = this; + return kd.maybe(this.activeSectionData, function(sectionData) { + const visibleFieldsConfig = VisibleFieldsConfig.create(null, self.gristDoc, sectionData.section, false); + const [fieldsDraggable, hiddenFieldsDraggable] = visibleFieldsConfig.buildSectionFieldsConfigHelper({ + visibleFields: { itemCreateFunc: getLabelFunc }, + hiddenFields: {itemCreateFunc: getLabelFunc } + }); + return dom('div', + dom.autoDispose(visibleFieldsConfig), + kf.collapsible(function(isCollapsed) { + return [ + kf.collapserLabel(isCollapsed, 'Visible Fields', kd.toggleClass('view_config_field_group', true)), + dom.testId('ViewConfigTab_visibleFields'), + fieldsDraggable, + ]; + }, false), + + kf.collapsible(function(isCollapsed) { + return [ + kf.collapserLabel(isCollapsed, 'Hidden Fields', kd.toggleClass('view_config_field_group', true)), + dom.testId('ViewConfigTab_hiddenFields'), + hiddenFieldsDraggable, + ]; + }, false), + ); + }); +}; + +// Builds object with ViewConfigTab dom builder and settings for the sidepane. +ViewConfigTab.prototype.buildConfigDomObj = function() { + return [{ + 'buildDom': this._buildNameDom.bind(this), + 'keywords': ['view', 'name', 'title'] + }, { + 'buildDom': this._buildSectionNameDom.bind(this), + 'keywords': ['section', 'viewsection', 'name', 'title'] + }, { + 'buildDom': this._buildAdvancedSettingsDom.bind(this), + 'keywords': ['table', 'demand', 'ondemand', 'big'] + }, { + 'header': true, + 'label': 'Summarize', + 'showObs': this._summaryConfig.isSummarySection, + 'items': [{ + 'buildDom': () => this._summaryConfig.buildSummaryConfigDom(), + 'keywords': ['section', 'summary', 'summarize', 'group', 'breakdown'] + }] + }, { + 'header': true, + 'label': 'Sort', + 'items': [{ + 'buildDom': this.buildSortDom.bind(this), + 'keywords': ['section', 'sort', 'order'] + }] + }, { + 'header': true, + 'label': 'Filter', + 'items': [{ + 'buildDom': this._buildFilterDom.bind(this), + 'keywords': ['section', 'filters'] + }] + }, { + 'header': true, + 'label': 'Link Sections', + 'items': [{ + 'buildDom': this._buildLinkDom.bind(this), + 'keywords': ['section', 'view', 'linking', 'edit', 'autoscroll', 'autofilter'] + }] + }, { + 'header': true, + 'label': 'Customize Detail View', + 'showObs': this.isDetail, + 'items': [{ + 'buildDom': this._buildDetailTypeDom.bind(this), + 'keywords': ['section', 'detail'] + }, { + 'buildDom': this._buildThemeDom.bind(this), + 'keywords': ['section', 'theme', 'appearance', 'detail'] + }, { + 'buildDom': this._buildLayoutDom.bind(this), + 'keywords': ['section', 'layout', 'arrangement', 'rearrange'] + }] + }, { + 'header': true, + 'label': 'Customize Grid View', + 'showObs': this.isGrid, + 'items': [{ + 'buildDom': this._buildGridStyleDom.bind(this), + 'keywords': ['section', 'zebra', 'stripe', 'appearance', 'grid', 'gridlines', 'style', 'border'] + }] + }, { + 'header': true, + 'label': 'Chart', + 'showObs': this.isChart, + 'items': [{ + 'buildDom': () => this._buildChartConfigDom() + }] + }, { + 'header': true, + 'label': 'Custom View', + 'showObs': this.isCustom, + 'items': this._buildCustomTypeItems(), + 'keywords': ['section', 'custom'] + }, { + 'header': true, + 'label': 'Column Display', + 'items': [{ + 'buildDom': this._buildSectionFieldsConfig.bind(this), + 'keywords': ['section', 'fields', 'hidden', 'hide', 'show', 'visible'] + }] + }]; +}; + +ViewConfigTab.prototype.buildSortDom = function() { + return grainjsDom.maybe(this.activeSectionData, (sectionData) => { + const section = sectionData.section; + + // Computed to indicate if sort has changed from saved. + const hasChanged = Computed.create(null, (use) => + !isEqual(use(section.activeSortSpec), parseSortColRefs(use(section.sortColRefs)))); + + // Computed array of sortable columns. + const columns = Computed.create(null, (use) => { + // Columns is an observable holding an observable array - must call 'use' on it 2x. + const cols = use(use(use(section.table).columns)); + return cols.filter(col => !use(col.isHiddenCol)) + .map(col => ({ + label: use(col.colId), + value: col.getRowId(), + icon: 'FieldColumn' + })); + }); + + // KoArray of sortRows used to create the draggableList. + const sortRows = koArray.syncedKoArray(section.activeSortSpec); + + // Sort row create function for each sort row in the draggableList. + const rowCreateFn = sortRef => + this._buildSortRow(sortRef, section.activeSortSpec.peek(), columns); + + // Reorder function called when sort rows are reordered via dragging. + const reorder = (...args) => { + const spec = reorderSortRefs(section.activeSortSpec.peek(), ...args); + this._saveSort(spec); + }; + + return grainjsDom('div', + grainjsDom.autoDispose(hasChanged), + grainjsDom.autoDispose(columns), + grainjsDom.autoDispose(sortRows), + // Sort rows. + kf.draggableList(sortRows, rowCreateFn, { + reorder, + removeButton: false, + drag_indicator: cssDragger, + itemClass: cssDragRow.className + }), + // Add to sort btn & menu & fake sort row. + this._buildAddToSortBtn(columns), + // Update/save/reset buttons visible when the sort has changed. + cssRow( + grainjsDom.maybe(hasChanged, () => [ + primaryButton('Save', {style: 'margin-right: 8px;'}, + grainjsDom.on('click', () => { section.activeSortJson.save(); }), + testId('sort-save'), + grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly), + ), + // Let's use same label (revert) as the similar button which appear in the view section. + // menu. + basicButton('Revert', + grainjsDom.on('click', () => { section.activeSortJson.revert(); }), + testId('sort-reset') + ) + ]), + cssFlex(), + grainjsDom.maybe(section.isSorted, () => + basicButton('Update Data', {style: 'margin-left: 8px; white-space: nowrap;'}, + grainjsDom.on('click', () => { updatePositions(this.gristDoc, section); }), + testId('sort-update'), + grainjsDom.show((use) => use(use(section.table).supportsManualSort)), + grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly), + ) + ), + grainjsDom.show((use) => use(hasChanged) || use(section.isSorted)) + ), + testId('sort-menu') + ); + }); +}; + +// Builds a single row of the sort dom +// Takes the sortRef (signed colRef), current sortSpec and array of column select options to show +// in the column select dropdown. +ViewConfigTab.prototype._buildSortRow = function(sortRef, sortSpec, columns) { + // sortRef is a rowId of a column or its negative value (indicating descending order). + const colRef = Math.abs(sortRef); + // Computed to show the selected column at the sortSpec index and to update the + // sortSpec on write. + const col = Computed.create(null, () => colRef); + col.onWrite((newRef) => { + const idx = sortSpec.findIndex(_sortRef => _sortRef === sortRef); + const swapIdx = sortSpec.findIndex(_sortRef => Math.abs(_sortRef) === newRef); + // If the selected ref is already present, swap it with the old ref. + // Maintain sort order in each case for simplicity. + if (swapIdx > -1) { sortSpec.splice(swapIdx, 1, sortSpec[swapIdx] > 0 ? colRef : -colRef); } + if (colRef !== newRef) { sortSpec.splice(idx, 1, sortRef > 0 ? newRef : -newRef); } + this._saveSort(sortSpec); + }); + return cssSortRow( + grainjsDom.autoDispose(col), + cssSortSelect( + select(col, columns) + ), + cssSortIconPrimaryBtn('Sort', + grainjsDom.style('transform', sortRef < 0 ? 'none' : 'scaleY(-1)'), + grainjsDom.on('click', () => { + this._saveSort(flipColDirection(sortSpec, sortRef)); + }), + testId('sort-order'), + testId(sortRef < 0 ? 'sort-order-desc' : 'sort-order-asc') + ), + cssSortIconBtn('Remove', + grainjsDom.on('click', () => { + const _idx = sortSpec.findIndex(c => c === sortRef); + if (_idx !== -1) { + sortSpec.splice(_idx, 1); + this._saveSort(sortSpec); + } + }), + testId('sort-remove') + ), + testId('sort-row') + ); +}; + +// Build the button to open the menu to add a sort item to the sort dom. +// Takes the full array of sortable column select options. +ViewConfigTab.prototype._buildAddToSortBtn = function(columns) { + // Observable indicating whether the add new column row is visible. + const showAddNew = Observable.create(null, false); + return [ + // Add column button. + cssRow( + grainjsDom.autoDispose(showAddNew), + cssTextBtn( + cssPlusIcon('Plus'), 'Add Column', + testId('sort-add') + ), + grainjsDom.hide(showAddNew), + grainjsDom.on('click', () => { showAddNew.set(true); }), + ), + // Fake add column row that appears only when the menu is open to select a new column + // to add to the sort. Immediately destroyed when menu is closed. + grainjsDom.maybe((use) => use(showAddNew) && use(columns), _columns => { + const col = Observable.create(null, 0); + const currentSection = this.activeSectionData().section; + const currentSortSpec = currentSection.activeSortSpec(); + const specRowIds = new Set(currentSortSpec.map(_sortRef => Math.abs(_sortRef))); + // Function called when a column select value is clicked. + const onClick = (_col) => { + showAddNew.set(false); // Remove add row ASAP to prevent flickering + addToSort(currentSection.activeSortSpec, _col.value); + }; + const menuCols = _columns + .filter(_col => !specRowIds.has(_col.value)) + .map(_col => + menuItem(() => onClick(_col), + cssMenuIcon(_col.icon), + _col.label, + testId('sort-add-menu-row') + ) + ); + return cssRow(cssSortRow( + dom.autoDispose(col), + cssSortSelect( + select(col, [], {defaultLabel: 'Add Column'}), + menu(() => [ + menuCols, + grainjsDom.onDispose(() => { showAddNew.set(false); }) + ], { + // Trigger to make menu open immediately + trigger: [(elem, ctl) => { + ctl.open(); + grainjsDom.onElem(elem, 'click', () => { ctl.close(); }); + }], + stretchToSelector: `.${cssSortSelect.className}` + }) + ), + cssSortIconPrimaryBtn('Sort', + grainjsDom.style('transform', 'scaleY(-1)') + ), + cssSortIconBtn('Remove') + )); + }) + ]; +}; + +ViewConfigTab.prototype._saveSort = function(sortSpec) { + this.activeSectionData().section.activeSortSpec(sortSpec); +}; + +ViewConfigTab.prototype._buildNameDom = function() { + return kf.row( + 1, dom('div.glyphicon.glyphicon-tasks.config_icon'), + 4, kf.label('View'), + 13, kf.text(this.viewModel.name, {}, dom.testId('ViewManager_viewNameInput')) + ); +}; + +ViewConfigTab.prototype._buildSectionNameDom = function() { + return kd.maybe(this.activeSectionData, function(sectionData) { + return kf.row( + 1, dom('div.glyphicon.glyphicon-credit-card.config_icon'), + 4, kf.label('Section'), + 13, kf.text(sectionData.section.titleDef, {}, dom.testId('ViewConfigTab_sectionNameInput')) + ); + }); +}; + +ViewConfigTab.prototype._makeOnDemand = function(table) { + // After saving the changed setting, force the reload of the document. + const onConfirm = () => { + return table.onDemand.saveOnly(!table.onDemand.peek()) + .then(() => { + return this.gristDoc.docComm.reloadDoc() + .catch((err) => { + // Ignore the expected error from the socket shutdown that we asked for. + if (!err.message.includes('GristWSConnection disposed')) { + throw err; + } + }) + }); + } + + if (table.onDemand()) { + showConfirmDialog('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm, + dom('div', 'If you unmark table ', dom('b', table), ' as On-Demand, ' + + 'its data will be loaded into the calculation engine and will be available ' + + 'for use in formulas. For a big table, this may greatly increase load times.', + dom('br'), 'Changing this setting will reload the document for all users.') + ); + } else { + showConfirmDialog('Make table On-Demand?', 'Make On-Demand', onConfirm, + dom('div', 'If you make table ', dom('b', table), ' On-Demand, ' + + 'its data will no longer be loaded into the calculation engine and will not be available ' + + 'for use in formulas. It will remain available for viewing and editing.', + dom('br'), 'Changing this setting will reload the document for all users.') + ); + } +}; + +ViewConfigTab.prototype._buildAdvancedSettingsDom = function() { + return kd.maybe(() => { + const s = this.activeSectionData(); + return s && !s.section.table().summarySourceTable() ? s : null; + }, (sectionData) => { + + const table = sectionData.section.table(); + const isCollapsed = ko.observable(true); + return [ + kf.collapserLabel(isCollapsed, 'Advanced settings', dom.testId('ViewConfig_advanced')), + kf.helpRow(kd.hide(isCollapsed), + 'Big tables may be marked as "on-demand" to avoid loading them into the data engine.', + kd.style('text-align', 'left'), + kd.style('margin-top', '1.5rem') + ), + kf.row(kd.hide(isCollapsed), + kf.label('Table ', dom('b', kd.text(table.tableId)), ':') + ), + kf.row(kd.hide(isCollapsed), + kf.buttonGroup(kf.button(() => this._makeOnDemand(table), + kd.text(() => table.onDemand() ? 'Unmark On-Demand' : 'Make On-Demand'), + dom.testId('ViewConfig_onDemandBtn') + )) + ), + ]; + }); +}; + +ViewConfigTab.prototype._buildDetailTypeDom = function() { + return kd.maybe(this.activeSectionData, (sectionData) => { + var section = sectionData.section; + if (this.isDetail()) { + return kf.row( + 1, kf.label('Type'), + 1, kf.buttonSelect(section.parentKey, + kf.optionButton('detail', 'List', dom.testId('ViewConfigTab_card')), + kf.optionButton('single', 'Single', dom.testId('ViewConfigTab_detail')) + ) + ); + } + }); +}; + +ViewConfigTab.prototype._buildFilterDom = function() { + return kd.maybe(this.activeSectionData, sectionData => { + let section = sectionData.section; + return dom('div', + kf.row( + 1, dom('div.glyphicon.glyphicon-filter.config_icon'), + 4, kf.label('Filters'), + 13, dom('div.kf_elem', kd.foreach(section.viewFields(), field => { + return dom('div.filter_list', kd.maybe(field.activeFilter, () => { + return dom('div.token', + dom('span.token-label', field.label()), + dom('span.close.glyphicon.glyphicon-remove', + dom.on('click', () => { field.activeFilter(''); }) + ) + ); + })); + })) + ), + grainjsDom.maybe(section.filterSpecChanged, () => { + return kf.prompt( + kf.liteButtonGroup( + kf.liteButton(() => section.saveFilters(), + dom('span.config_icon.left_icon.glyphicon.glyphicon-save'), 'Save', + dom.testId('ViewConfigTab_saveFilter'), + kd.toggleClass('disabled', () => this.gristDoc.isReadonlyKo()), + ), + kf.liteButton(() => section.revertFilters(), + dom('span.config_icon.left_icon.glyphicon.glyphicon-refresh'), 'Reset', + dom.testId('ViewConfigTab_resetFilter') + ) + ) + ); + }) + ); + }); +}; + +ViewConfigTab.prototype._buildThemeDom = function() { + return kd.maybe(this.activeSectionData, (sectionData) => { + var section = sectionData.section; + if (this.isDetail()) { + const theme = Computed.create(null, (use) => use(section.themeDef)); + theme.onWrite(val => section.themeDef.setAndSave(val)); + return cssRow( + dom.autoDispose(theme), + select(theme, [ + {label: 'Form', value: 'form' }, + {label: 'Compact', value: 'compact'}, + {label: 'Blocks', value: 'blocks' }, + ]), + testId('detail-theme') + ); + } + }); +}; + +ViewConfigTab.prototype._buildGridStyleDom = function() { + + return kd.maybe(this.activeSectionData, (sectionData) => { + var section = sectionData.section; + return dom('div', + kf.row( + 15, kf.label('Horizontal Gridlines'), + 2, kf.checkbox(section.optionsObj.prop('horizontalGridlines'), + dom.testId('ViewConfigTab_hGridButton')) + ), + kf.row( + 15, kf.label('Vertical Gridlines'), + 2, kf.checkbox(section.optionsObj.prop('verticalGridlines'), + dom.testId('ViewConfigTab_vGridButton')) + ), + kf.row( + 15, kf.label('Zebra Stripes'), + 2, kf.checkbox(section.optionsObj.prop('zebraStripes'), + dom.testId('ViewConfigTab_zebraStripeButton')) + ), + dom.testId('ViewConfigTab_gridOptions') + ); + }); +}; + +ViewConfigTab.prototype._buildChartConfigDom = function() { + return grainjsDom.maybe(this.viewModel.activeSection, buildChartConfigDom); +}; + +ViewConfigTab.prototype._buildLayoutDom = function() { + return kd.maybe(this.activeSectionData, (sectionData) => { + if (this.isDetail()) { + const view = sectionData.section.viewInstance.peek(); + const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor()); + return cssRow({style: 'margin-top: 16px;'}, + kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()), + primaryButton('Edit Card Layout', + dom.autoDispose(layoutEditorObs), + dom.on('click', () => commands.allCommands.editLayout.run()), + grainjsDom.hide(layoutEditorObs), + testId('detail-edit-layout') + ) + ); + } + }); +}; + +ViewConfigTab.prototype._buildLinkDom = function() { + var linkSpecChanged = ko.computed(() => + !this.viewModel.viewSections().all().every(vs => vs.isActiveLinkSaved())); + + return dom('div', + dom.autoDispose(linkSpecChanged), + kf.buttonGroup(kf.checkButton(this.viewModel.isLinking, + dom('span', 'Edit Links', dom.testId('viewConfigTab_link')))), + kd.maybe(this.activeSectionData, (sectionData) => { + const section = sectionData.section; + // This section option affects section linking: it tells a link-target section to show rows + // matching any of the rows in link-source section, not only the current cursor row. + const filterByAllShown = section.optionsObj.prop('filterByAllShown'); + return kf.row( + 15, kf.label('Filter by all shown'), + 2, kf.checkbox(filterByAllShown, dom.testId('ViewConfigTab_filterByAll')) + ); + }), + kd.maybe(linkSpecChanged, () => + kf.prompt( + kf.liteButtonGroup( + kf.liteButton(() => { + commands.allCommands.saveLinks.run(); + this.viewModel.isLinking(false); + }, dom('span.config_icon.left_icon.glyphicon.glyphicon-save'), 'Save'), + kf.liteButton(() => commands.allCommands.revertLinks.run(), + dom('span.config_icon.left_icon.glyphicon.glyphicon-refresh'), 'Reset' + ) + ) + ) + ) + ); +}; + +/** + * Builds the three items for configuring a `Custom View`: + * 1) Mode picker: let user choose between 'url' and 'plugin' mode + * 2) Show if 'url' mode: let user enter the url + * 3) Show if 'plugin' mode: let user pick a plugin and a section from the list of available plugin. + */ +ViewConfigTab.prototype._buildCustomTypeItems = function() { + const docPluginManager = this.gristDoc.docPluginManager; + const activeSection = this.viewModel.activeSection; + + // all available custom sections grouped by their plugin id + const customSections = _.groupBy(CustomSectionElement.getSections(docPluginManager.pluginsList), s => s.pluginId); + + // all plugin ids which have custom sections + const allPlugins = Object.keys(customSections); + + // the list of customSections of the selected plugin (computed) + const customSectionIds = ko.pureComputed(() => { + const sections = customSections[this.viewModel.activeSection().customDef.pluginId()] || []; + return sections.map(({sectionId}) => sectionId); + }); + + return [{ + + // 1) + buildDom: () => kd.scope(activeSection, ({customDef}) => kf.buttonSelect(customDef.mode, + kf.optionButton('url', 'URL', dom.testId('ViewConfigTab_customView_url')), + kf.optionButton('plugin', 'Plugin', dom.testId('ViewConfigTab_customView_plugin')))) + }, { + + // 2) + showObs: () => activeSection().customDef.mode() === "url", + buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div', + kf.row(18, kf.text(customDef.url, {placeholder: "Full URL of webpage to show"}, dom.testId('ViewConfigTab_url'))), + kf.row(5, "Access", 13, dom(kf.select(customDef.access, ['none', 'read table', 'full']), dom.testId('ViewConfigTab_customView_access'))), + kf.helpRow('none: widget has no access to document.', + kd.style('text-align', 'left'), + kd.style('margin-top', '1.5rem')), + kf.helpRow('read table: widget can read the selected table.', + kd.style('text-align', 'left'), + kd.style('margin-top', '1.5rem')), + kf.helpRow('full: widget can read, modify, and copy the document.', + kd.style('text-align', 'left'), + kd.style('margin-top', '1.5rem')) + )), + }, { + + // 3) + showObs: () => activeSection().customDef.mode() === "plugin", + buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div', + kf.row(5, "Plugin: ", 13, kf.text(customDef.pluginId, {}, {list: "list_plugin"}, dom.testId('ViewConfigTab_customView_pluginId'))), + kf.row(5, "Section: ", 13, kf.text(customDef.sectionId, {}, {list: "list_section"}, dom.testId('ViewConfigTab_customView_sectionId'))), + // For both `customPlugin` and `selectedSection` it is possible for the value not to be in the + // list of options. Combining and allows both to freely edit the value with + // keyboard and to select it from a list. Although the content of the list seems to be + // filtered by the current value, which could confuse user into thinking that there are no + // available options. I think it would be better to have the full list always, but it seems + // harder to accomplish and is left as a TODO. + dom('datalist#list_plugin', kd.foreach(koArray(allPlugins), value => dom('option', {value}))), + dom('datalist#list_section', kd.scope(customSectionIds, sections => kd.foreach(koArray(sections), (value) => dom('option', {value})))) + )) + }]; +}; + +const cssMenuIcon = styled(cssIcon, ` + margin: 0 8px 0 0; + + .${cssMenuItem.className}-sel > & { + background-color: ${colors.light}; + } +`); + +// Note that the width is set to 0 so that flex-shrink works properly with long text values. +const cssSortSelect = styled('div', ` + flex: 1 1 0px; + margin: 0 6px 0 0; + min-width: 0; +`); + +const cssSortIconBtn = styled(cssIcon, ` + flex: none; + margin: 0 6px; + cursor: pointer; + background-color: ${colors.slate}; + + &:hover { + background-color: ${colors.dark}; + } +`); + +const cssSortIconPrimaryBtn = styled(cssSortIconBtn, ` + background-color: ${colors.lightGreen}; + + &:hover { + background-color: ${colors.darkGreen}; + } +`); + +const cssTextBtn = styled('div', ` + color: ${colors.lightGreen}; + cursor: pointer; + height: 29px; + + &:hover { + color: ${colors.darkGreen}; + } +`); + +const cssPlusIcon = styled(cssIcon, ` + background-color: ${colors.lightGreen}; + cursor: pointer; + margin: 0px 4px 3px 0; + + .${cssTextBtn.className}:hover > & { + background-color: ${colors.darkGreen}; + } +`); + +const cssDragRow = styled('div', ` + display: flex !important; + align-items: center; + margin: 0 16px 0px 0px; + & > .kf_draggable_content { + margin: 6px 0; + flex: 1 1 0px; + min-width: 0px; + } +`); + +const cssSortRow = styled('div', ` + display: flex; + align-items: center; + width: 100%; +`); + +const cssFlex = styled('div', ` + flex: 1 1 0; +`); + +module.exports = ViewConfigTab; diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css new file mode 100644 index 00000000..66eed240 --- /dev/null +++ b/app/client/components/ViewLayout.css @@ -0,0 +1,234 @@ +.view_leaf { + position: relative; + flex: 1 1 0px; +} + +.viewsection_title { + background-color: #e5e5e5; + color: black; + flex-shrink: 0; + align-items: baseline; + line-height: 2; + + cursor: default; +} + +.active_section > .viewsection_title { + background-color: #3e568e; + color: white; +} + +.viewsection_content.newui > .viewsection_title { + height: 24px; + margin-left: -16px; /* to include drag handle that shows up on hover */ + color: var(--grist-color-slate); + background-color: unset; + font-size: var(--grist-small-font-size); + font-weight: 500; + text-transform: uppercase; + line-height: inherit; +} + +.active_section.newui > .viewsection_title { + background-color: unset; + color: var(--grist-color-slate); +} + +.viewsection_titletext { + cursor: text; + overflow: hidden; +} + +.viewsection_content { + background-color: #ffffff; + margin: 4px; + overflow: visible; + box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.8); +} + +.viewsection_content.newui { + margin: 12px; + box-shadow: none; +} + +.viewsection_title_colorbox { + width: 16px; + height: 16px; + border-radius: 8px; + margin: auto .5rem auto 0; + box-shadow: inset 0px 0px 5px rgba(0,0,0,0.5); +} + +.viewsection_drag_indicator { + visibility: hidden; + margin: auto; + padding: 0px 2px; + top: 0px; +} + +/* TODO should be switched to use new icon */ +.viewsection_drag_indicator.newui { + width: 16px; + height: 16px; + margin: 0; + padding: 0px; +} + +.viewsection_title:hover .viewsection_drag_indicator.layout_grabbable { + visibility: visible; + z-index: 100; /* ensure it's above the resizer line, since it's hard to grab otherwise */ +} + +.viewsection_btn { + display: inline-block; + padding: 0px 4px; +} + +.viewsection_settings { + display: inline-block; + vertical-align: middle; +} + +.viewsection_status_icons { + color: #999999; +} + +.viewsection_status_icons > .status_icon:hover { + color: black; +} + +.viewsection_status_icons.active_section { + color: #AEC6FE; +} + +.viewsection_status_icons.active_section > .status_icon:hover { + color: white; +} + +.viewsection_truncated { + position: absolute; + right: 8px; + bottom: 8px; + background-color: red; + color: white; + z-index: 1; +} + +.link_direction_icon { + display: inline-block; + position: relative; + vertical-align: top; + width: 1.9rem; + height: 1.2rem; + margin: .25rem -.1rem 0 -.2rem; +} + +.viewsection_status_icons.active_section > .status_icon.unsaved_changes { + text-shadow: 0px 0px 5px #fff; + color: #FFFFFF; +} + +.view_data_pane_container { + position: relative; + flex: auto; +} + +.viewsection_content.newui > .view_data_pane_container { + border: 1px solid var(--grist-color-dark-grey); +} + +@media not print { +.active_section.newui > .view_data_pane_container { + box-shadow: -2px 0 0 0px var(--grist-color-light-green); + border-left: 1px solid var(--grist-color-light-green); +} + +.active_section.newui > .view_data_pane_container.viewsection_type_detail { + /* this color is a translucent version of grist-color-light-green */ + box-shadow: -2px 0 0 0px var(--grist-color-inactive-cursor); + border-left: 1px solid var(--grist-color-inactive-cursor); +} +} + +.disable_viewpane { + justify-content: center; + text-align: center; + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.1); + font-size: 12pt; +} + +.status_icon.unsaved_changes { + text-shadow: 0px 0px 5px #8A8A8A; + color: #FFFFFF +} + +.link_direction_icon.has_in_arrow { + margin-left: .3rem; +} + +.link_direction_icon.has_out_arrow { + margin-right: .2rem; +} + +.link_icon { + position: absolute; + font-size: 1.05rem; + left: .45rem; +} + +.link_out_arrow { + position: absolute; + top: .5rem; + left: 1.3rem; + font-size: .65rem; + transform: scale(.8, 1); +} + +.link_in_arrow { + position: absolute; + top: .05rem; + left: 0; + font-size: .65rem; + transform: scale(.8, 1); +} + +.sort_icon { + display: inline-block; + position: relative; + vertical-align: top; + width: 1.2rem; + height: 1.2rem; + font-size: 1.0rem; + margin: .25rem .1rem 0 .3rem; +} + +.filter_icon { + display: inline-block; + position: relative; + vertical-align: top; + width: 1.2rem; + height: 1.2rem; + font-size: 1.0rem; + margin: .25rem .1rem 0 .3rem; +} + +.shaking { + animation: shake 0.4s ease; + transform: translate(0, 0); +} + +@keyframes shake { + 10%, 90% { + transform: translate(2px, 0); + } + 30%, 70% { + transform: translate(-3px, 0); + } + 50% { + transform: translate(3px, 0); + } +} diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts new file mode 100644 index 00000000..1ee2007d --- /dev/null +++ b/app/client/components/ViewLayout.ts @@ -0,0 +1,270 @@ +import * as BaseView from 'app/client/components/BaseView'; +import {ChartView} from 'app/client/components/ChartView'; +import * as commands from 'app/client/components/commands'; +import {CustomView} from 'app/client/components/CustomView'; +import * as DetailView from 'app/client/components/DetailView'; +import * as GridView from 'app/client/components/GridView'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {Layout} from 'app/client/components/Layout'; +import {LayoutEditor} from 'app/client/components/LayoutEditor'; +import {Delay} from 'app/client/lib/Delay'; +import {createObsArray} from 'app/client/lib/koArrayWrap'; +import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {reportError} from 'app/client/models/errors'; +import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; +import {testId} from 'app/client/ui2018/cssVars'; +import {editableLabel} from 'app/client/ui2018/editableLabel'; +import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {mod} from 'app/common/gutil'; +import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, subscribe} from 'grainjs'; +import * as ko from 'knockout'; +import * as _ from 'underscore'; + +// tslint:disable:no-console + +const viewSectionTypes: {[key: string]: any} = { + record: GridView, + detail: DetailView, + chart: ChartView, + single: DetailView, + custom: CustomView, +}; + +function getInstanceConstructor(parentKey: string) { + const Cons = viewSectionTypes[parentKey]; + if (!Cons) { + console.error('ViewLayout error: requested an unsupported section type:', parentKey); + } + // Default to GridView if no valid constructor + return Cons || viewSectionTypes.record; +} + +class ViewSectionHelper extends Disposable { + private _instance = Holder.create(this); + + constructor(gristDoc: GristDoc, vs: ViewSectionRec) { + super(); + this.onDispose(() => vs.viewInstance(null)); + + this.autoDispose(subscribe((use) => { + // Rebuild the section when its type changes or its underlying table. + const table = use(vs.table); + const Cons = getInstanceConstructor(use(vs.parentKey)); + this._instance.clear(); + if (table.getRowId()) { + this._instance.autoDispose(Cons.create(gristDoc, vs)); + } + vs.viewInstance(this._instance.get()); + })); + } +} + +/** + * ViewLayout - Handles layout for a single page. + */ +export class ViewLayout extends DisposableWithEvents implements IDomComponent { + public docModel = this.gristDoc.docModel; + public viewModel: ViewRec; + public layoutSpec: ko.Computed; + + private _freeze = false; + private _layout: any; + private _sectionIds: number[]; + + constructor(public readonly gristDoc: GristDoc, viewId: number) { + super(); + this.viewModel = this.docModel.views.getRowModel(viewId); + + // A Map from viewSection RowModels to corresponding View class instances. + // TODO add a test that creating / deleting a section creates/destroys one instance, and + // switching pages destroys all instances. + const viewSectionObs = createObsArray(this, this.viewModel.viewSections()); + this.autoDispose(computedArray(viewSectionObs, (vs, i, compArr) => + ViewSectionHelper.create(compArr, gristDoc, vs))); + + // Update the stored layoutSpecObj with any missing fields that are present in viewFields. + this.layoutSpec = this.autoDispose(ko.computed( + () => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj())) + .extend({rateLimit: 0})); + + this._layout = this.autoDispose(Layout.create(this.layoutSpec(), + this._buildLeafContent.bind(this), true)); + this._sectionIds = this._layout.getAllLeafIds(); + + // When the layoutSpec changes by some means other than the layout editor, rebuild. + // This includes adding/removing sections and undo/redo. + this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this._rebuildLayout(spec))); + + const layoutSaveDelay = this.autoDispose(new Delay()); + + this.listenTo(this._layout, 'layoutUserEditStop', () => { + layoutSaveDelay.schedule(1000, () => { + if (!this._layout) { return; } + (this.viewModel.layoutSpecObj as any).setAndSave(this._layout.getLayoutSpec()); + this._onResize(); + }); + }); + + // Do not save if the user has started editing again. + this.listenTo(this._layout, 'layoutUserEditStart', () => layoutSaveDelay.cancel()); + + this.autoDispose(LayoutEditor.create(this._layout)); + + // Add disposal of this._layout after layoutEditor, so that it gets disposed first, and + // layoutEditor doesn't attempt to update it in its own disposal logic. + this.onDispose(() => this._layout.dispose()); + + this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this)); + + // It's hard to detect a click or mousedown on a third-party iframe + // (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript). + this.listenTo(this.gristDoc.app, 'clipboard_blur', this._maybeFocusInSection); + + const commandGroup = { + deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); }, + nextSection: () => { this._otherSection(+1); }, + prevSection: () => { this._otherSection(-1); }, + }; + this.autoDispose(commands.createGroup(commandGroup, this, true)); + } + + public buildDom() { + return this._layout.rootElem; + } + + // Freezes the layout until the passed in promise resolves. This is useful to achieve a single + // layout rebuild when multiple user actions needs to apply, simply pass in a promise that resolves + // when all user actions have resolved. + public async freezeUntil(promise: Promise): Promise { + this._freeze = true; + try { + await promise; + } finally { + this._freeze = false; + this._rebuildLayout(this.layoutSpec.peek()); + } + } + + // Removes a view section from the current view. Should only be called if there is + // more than one viewsection in the view. + private _removeViewSection(viewSectionRowId: number) { + this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); + } + + private _buildLeafContent(sectionRowId: number) { + // Creating normal section dom + const vs: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionRowId); + return dom('div.view_leaf.viewsection_content.flexvbox.flexauto', + testId(`viewlayout-section-${sectionRowId}`), + this.gristDoc.app.addNewUIClass(), + dom.cls('active_section', vs.hasFocus), + dom.maybe((use) => use(vs.viewInstance) !== null, () => dom('div.viewsection_title.flexhbox', + dom('span.viewsection_drag_indicator.glyphicon.glyphicon-option-vertical', + this.gristDoc.app.addNewUIClass(), + // Makes element grabbable only if grist is not readonly. + dom.cls('layout_grabbable', (use) => !use(this.gristDoc.isReadonlyKo))), + dom('div.flexitem.flexhbox', + dom('span.viewsection_titletext', editableLabel( + fromKo(vs.titleDef), + (val) => vs.titleDef.saveOnly(val), + testId('viewsection-title'), + )), + ), + dom.maybe(vs.viewInstance, (viewInstance: BaseView) => viewInstance.buildTitleControls()), + dom('span.viewsection_buttons', + viewSectionMenu(this.docModel, vs, this.viewModel, this.gristDoc.isReadonly, this.gristDoc.app.useNewUI) + ) + )), + dom.maybe(vs.viewInstance, (viewInstance) => + dom('div.view_data_pane_container.flexvbox', + dom.maybe(viewInstance.disableEditing, () => + dom('div.disable_viewpane.flexvbox', 'No data') + ), + dom.maybe(viewInstance.isTruncated, () => + dom('div.viewsection_truncated', 'Not all data is shown') + ), + dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), + viewInstance.viewPane + ) + ), + dom.on('mousedown', () => { this.viewModel.activeSectionId(sectionRowId); }), + ); + } + + /** + * If there is no layout saved, we can create a default layout just from the list of fields for + * this view section. By default we just arrange them into a list of rows, two fields per row. + */ + private _updateLayoutSpecWithSections(spec: object) { + // We use tmpLayout as a way to manipulate the layout before we get a final spec from it. + const tmpLayout = Layout.create(spec, (leafId: number) => dom('div'), true); + + const specFieldIds = tmpLayout.getAllLeafIds(); + const viewSectionIds = this.viewModel.viewSections().all().map(function(f) { return f.getRowId(); }); + + function addToSpec(leafId: number) { + const newBox = tmpLayout.buildLayoutBox({ leaf: leafId }); + const rows = tmpLayout.rootBox().childBoxes.peek(); + const lastRow = rows[rows.length - 1]; + if (rows.length >= 1 && lastRow.isLeaf()) { + // Add a new child to the last row. + lastRow.addChild(newBox, true); + } else { + // Add a new row. + tmpLayout.rootBox().addChild(newBox, true); + } + return newBox; + } + + // For any stale fields (no longer among viewFields), remove them from tmpLayout. + _.difference(specFieldIds, viewSectionIds).forEach(function(leafId) { + tmpLayout.getLeafBox(leafId).dispose(); + }); + + // For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a + // two-column layout, so add a new row, or a second box to the last row if it's a leaf. + _.difference(viewSectionIds, specFieldIds).forEach(function(leafId) { + // Only add the builder box if it hasn`t already been created + addToSpec(leafId); + }); + + spec = tmpLayout.getLayoutSpec(); + tmpLayout.dispose(); + return spec; + } + + private _rebuildLayout(layoutSpec: object) { + this._layout.buildLayout(layoutSpec, true); + this._onResize(); + this._sectionIds = this._layout.getAllLeafIds(); + } + + // Resizes the scrolly windows of all viewSection classes with a 'scrolly' property. + private _onResize() { + this.viewModel.viewSections().all().forEach(vs => { + const inst = vs.viewInstance.peek(); + if (inst) { + inst.onResize(); + } + }); + } + + // Select another section in cyclic ordering of sections. Order is counter-clockwise if given a + // positive `delta`, clockwise otherwise. + private _otherSection(delta: number) { + const sectionId = this.viewModel.activeSectionId.peek(); + const currentIndex = this._sectionIds.indexOf(sectionId); + const index = mod(currentIndex + delta, this._sectionIds.length); + + // update the active section id + this.viewModel.activeSectionId(this._sectionIds[index]); + } + + private _maybeFocusInSection() { + // If the focused element is inside a view section, make that section active. + const layoutBox = this._layout.getContainingBox(document.activeElement); + if (layoutBox && layoutBox.leafId) { + this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek()); + } + } +} diff --git a/app/client/components/ViewLinker.css b/app/client/components/ViewLinker.css new file mode 100644 index 00000000..1c9e9bd2 --- /dev/null +++ b/app/client/components/ViewLinker.css @@ -0,0 +1,114 @@ +.g_record_layout_linking { + position: absolute; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + justify-content: center; + -webkit-justify-content: center; + align-items: center; + -webkit-align-items: center; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 5; +} + +.linker_canvas { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + z-index: 10; +} + +.linker_save_btns { + position: absolute; + right: 10px; + top: 10px; + z-index: 100; + display: flex; +} + +.linker_btn { + width: 90px; + margin: 4px; + background-color: black; + padding: 5px; + border-radius: 2px; + text-align: center; + color: white; + cursor: pointer; + border: 1px solid #cdcbcb; +} + +.linker_btn.disabled { + color: #555; + cursor: default; +} + +.linker_btn_cancel { + color: #aaa; +} + +.linker_box { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + font-size: 1.5rem; +} + +.section_link { + margin: 4px 0; + background-color: black; + padding: 5px 10px; + border-radius: 2px; + cursor: default; + width: 100%; +} + +.view_link { + position: absolute; +} + +.view_link_icon { + color: #aaa; + cursor: pointer; +} + +.linker_box_header { + visibility: hidden; + font-size: 1.2rem; + font-weight: bold; + margin: 2px 0 -4px 0; + color: white; +} + +.linker_box_header.visible { + visibility: visible; +} + +.link_text { + margin-left: 25px; + color: #666; +} + +.selected_text, +.available_text { + color: white; +} + +.remove_link_icon:hover, +.view_link_icon:hover, +.selected_link { + color: white; +} + +.remove_link_icon { + color: #aaa; + margin: 0 0 0 8px; + font-size: 1.2rem; + cursor: pointer; +} diff --git a/app/client/components/ViewLinker.js b/app/client/components/ViewLinker.js new file mode 100644 index 00000000..60ebcb55 --- /dev/null +++ b/app/client/components/ViewLinker.js @@ -0,0 +1,334 @@ +var _ = require('underscore'); +var ko = require('knockout'); +var gutil = require('app/common/gutil'); +var dispose = require('../lib/dispose'); +var dom = require('../lib/dom'); +var kd = require('../lib/koDom'); +var commands = require('./commands'); + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +var G = require('../lib/browserGlobals').get('window', 'document', '$'); + + +//---------------------------------------------------------------------- +function ViewLinkerNode(section, column, tableId, primaryTableId) { + this.section = section; + this.sectionRef = section ? section.getRowId() : 0; + this.col = column; + this.colRef = column ? column.getRowId() : 0; + this.tableId = tableId; + this.primaryTableId = primaryTableId; + this.nodeId = this.sectionRef + ':' + this.colRef; // Uniquely identifies the node. + this.linkIconDom = null; +} + +ViewLinkerNode.prototype.isLinked = function() { + return this.section && this.section.activeLinkSrcSectionRef() && + this.section.activeLinkTargetColRef() === this.colRef; +}; + +ViewLinkerNode.prototype.isValidLinkTo = function(targetNode) { + let tablesMatch = (this.tableId === targetNode.tableId || + (this.primaryTableId === targetNode.primaryTableId && !this.col && !targetNode.col)); + + // There are table-to-table links (cursor sync), table-to-column (filter), column-to-table + // (cursor), and column-to-column (filter). + + // The table must match. + if (!tablesMatch) { return false; } + + // The target must not be already linked. + if (targetNode.section && targetNode.section.activeLinkSrcSectionRef()) { + return false; + } + + if (this.section) { + // The link must not create a cycle. + for (let sec = this.section; sec.getRowId(); sec = sec.linkSrcSection()) { + if (targetNode.sectionRef === sec.getRowId()) { + return false; + } + } + } + return true; +}; + + +ViewLinkerNode.prototype.linkCoord = function() { + var rect = this.linkIconDom.getBoundingClientRect(); + return { + left: rect.left + rect.width / 2, + top: rect.top + rect.height / 2 + }; +}; + +//---------------------------------------------------------------------- + +/** + * ViewLinker - Builds GUI for linking viewSections in viewModel + */ +function ViewLinker(viewModel) { + this.viewModel = viewModel; + this.viewSections = this.viewModel.viewSections().all(); + + this.clicked = ko.observable(null); + + // For each section, the pair (section, colRef) represents a linkable node for every reference + // column, and for colRef 0 (which corresponds to the special "id" column, or the table itself). + this.allNodes = []; + for (let section of this.viewSections) { + this.allNodes.push(...ViewLinker.createNodes(section, section.table())); + } + this.nodesBySection = _.groupBy(this.allNodes, 'sectionRef'); + this.nodesById = _.indexBy(this.allNodes, 'nodeId'); + + // A list of coordinate data objects for coordinates in no particular order. + this.coordinates = null; + + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundWindowResize = this.handleWindowResize.bind(this); + + this.autoDisposeCallback(function() { + G.$(G.window).off('mousemove', this.boundMouseMove); + G.$(G.window).off('resize', this.boundWindowResize); + }); + + this.canvas = this.autoDispose(dom('canvas.linker_canvas')); + this.buttons = this.autoDispose( + dom('div.linker_save_btns', + dom('div.linker_btn', 'Apply', + dom.on('click', () => { + this.viewModel.isLinking(false); + }) + ), + dom('div.linker_btn', 'Apply & Save', + dom.on('click', () => { + commands.allCommands.saveLinks.run(); + this.viewModel.isLinking(false); + }) + ), + dom('div.linker_btn', 'Cancel', + dom.on('click', () => { + commands.allCommands.revertLinks.run(); + this.viewModel.isLinking(false); + }) + ) + ) + ); + dom.id('grist-app').appendChild(this.canvas); + dom.id('view_content').appendChild(this.buttons); + this.ctx = this.canvas.getContext("2d"); + + // Must monitor link values and redraw arrows on changes since undo-ing/redo-ing may + // affect them. + this.autoDispose(ko.computed(() => { + for (let section of this.viewSections) { + section.linkSrcSectionRef(); + section.linkSrcColRef(); + section.linkTargetColRef(); + } + setTimeout(this.resetArrows.bind(this), 0); + })); + G.$(G.window).on('resize', this.boundWindowResize); + + this.handleWindowResize(); +} +dispose.makeDisposable(ViewLinker); + +ViewLinker.createNodes = function(section, table) { + const nodes = []; + nodes.push(new ViewLinkerNode(section, null, table.tableId(), + table.primaryTableId())); + for (let column of table.columns().peek()) { + let tableId = gutil.removePrefix(column.type(), "Ref:"); + if (tableId) { + nodes.push(new ViewLinkerNode(section, column, tableId)); + } + } + return nodes; +}; + +// Fills coordinates object with values and re-renders canvas. +ViewLinker.prototype.resetArrows = function() { + this.resetCoordinates(); + this.redrawArrows(); +}; + +ViewLinker.prototype.onClickNode = function(event, node) { + if (!this.clicked()) { + this.clicked(node); + G.$(G.window).on('mousemove', this.boundMouseMove); + } else { + this.connectLink(node); + this.clicked(null); + G.$(G.window).off('mousemove', this.boundMouseMove); + } +}; + +ViewLinker.prototype.onClickBackground = function(event) { + if (this.clicked()) { + this.clicked(null); + G.$(G.window).off('mousemove', this.boundMouseMove); + this.redrawArrows(); + } else { + this.viewModel.isLinking(false); + } +}; + +ViewLinker.prototype.handleMouseMove = function(event) { + this.redrawArrows(); + let coord = this.clicked().linkCoord(); + drawArrow(this.ctx, coord.left, coord.top, event.clientX, event.clientY); + this.ctx.stroke(); +}; + +ViewLinker.prototype.handleWindowResize = function(event) { + // Canvas must be scaled up in a particular way for improved resolution. + var w = G.$(G.window); + var windowWidth = w.innerWidth(); + var windowHeight = w.innerHeight(); + this.ctx.canvas.style.width = windowWidth + 'px'; + this.ctx.canvas.style.height = windowHeight + 'px'; + this.ctx.canvas.width = windowWidth * 2; + this.ctx.canvas.height = windowHeight * 2; + this.ctx.scale(2, 2); + setTimeout(this.resetArrows.bind(this), 0); +}; + +// To be called by each viewSection when the ViewLinker is active. +ViewLinker.prototype.buildLeafDom = function(viewSection) { + var sectionRef = viewSection.getRowId(); + var mainNode = this.nodesById[sectionRef + ':0']; + var sectionNodes = this.nodesBySection[sectionRef]; + var getOtherSections = () => this.clicked() && this.clicked().sectionRef !== sectionRef; + + return dom('div.g_record_layout_linking', + dom.on('click', event => { this.onClickBackground(event); }), + dom('div.linker_box', + dom('div.linker_box_section', + dom('div.linker_box_header', 'Scroll', + kd.toggleClass('visible', getOtherSections) + ), + this.buildLinkRowDom(viewSection, mainNode) + ), + dom('div.linker_box_columns', + dom('div.linker_box_header', 'Filter', + kd.toggleClass('visible', () => getOtherSections() && sectionNodes.length > 1) + ), + this.nodesBySection[sectionRef].map(node => { + if (!node.col) { return null; } + return this.buildLinkRowDom(viewSection, node); + }) + ) + ) + ); +}; + +ViewLinker.prototype.buildLinkRowDom = function(viewSection, node) { + var sectionRef = viewSection.getRowId(); + var tableId = viewSection.table().tableTitle(); + + var isAvailable = ko.computed(() => (this.clicked() ? + this.clicked().isValidLinkTo(node) : + // See if there are any nodes this could possibly link to. + this.allNodes.some(tgt => node.isValidLinkTo(tgt)) + )); + + return dom('div.section_link.section_link_' + sectionRef, + dom.autoDispose(isAvailable), + // Prevents closing on link mode when clicking in link boxes. + dom.on('click', event => { event.stopPropagation(); }), + dom('span.view_link.link_' + sectionRef + (node.col ? '_' + node.col.getRowId() : ''), + dom.on('click', event => { + if (isAvailable()) { this.onClickNode(event, node); } + }), + node.linkIconDom = + dom('span.glyphicon.glyphicon-link.view_link_icon', + // Use 'visibility' rather than 'display', to keep size and position available. + kd.style('visibility', () => isAvailable() ? 'visible' : 'hidden'), + kd.toggleClass('selected_link', () => (node.isLinked() || this.clicked() === node)) + ) + ), + dom('span.link_text', tableId + (node.col ? '.' + node.col.colId() : ''), + kd.toggleClass('available_text', isAvailable), + kd.toggleClass('selected_text', () => node.isLinked()), + dom('span.glyphicon.glyphicon-remove.remove_link_icon', + kd.style('visibility', () => + (node.isLinked() && !this.clicked() ? 'visible' : 'hidden')), + dom.on('click', () => this.removeNodeLinks(node)) + ) + ) + ); +}; + +// Initializes the coordinates array with linked node coordinates. +ViewLinker.prototype.resetCoordinates = function() { + this.coordinates = []; + for (let vs of this.viewSections) { + if (vs.activeLinkSrcSectionRef.peek()) { + let sourceNodeId = vs.activeLinkSrcSectionRef.peek() + ':' + vs.activeLinkSrcColRef.peek(); + let targetNodeId = vs.getRowId() + ':' + vs.activeLinkTargetColRef.peek(); + this.coordinates.push({ + 'from': this.nodesById[sourceNodeId].linkCoord(), + 'to': this.nodesById[targetNodeId].linkCoord() + }); + } + } +}; + +// Draws all pre-existing arrows on the canvas. +ViewLinker.prototype.redrawArrows = function() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.beginPath(); + this.coordinates.forEach(conn => + drawArrow(this.ctx, conn.from.left, conn.from.top, conn.to.left, conn.to.top)); +}; + +// Saves a new link from the clicked node to 'node' to the local list of links. +ViewLinker.prototype.connectLink = function(node) { + var sourceNode = this.clicked(); + var section = node.section; + section.activeLinkSrcSectionRef(sourceNode.sectionRef); + section.activeLinkSrcColRef(sourceNode.colRef); + section.activeLinkTargetColRef(node.colRef); + this.resetArrows(); +}; + +// Removes links from the given node. +ViewLinker.prototype.removeNodeLinks = function(node) { + if (!node.isLinked()) { return; } + var section = node.section; + section.activeLinkSrcSectionRef(0); + section.activeLinkSrcColRef(0); + section.activeLinkTargetColRef(0); + this.resetArrows(); +}; + +// Draws an orange arrow with a black outline in context ctx from (origX, origY) to (x, y). +function drawArrow(ctx, origX, origY, x, y){ + ctx.strokeStyle = 'black'; + ctx.lineWidth = 4; + ctx.lineCap = 'round'; + _drawBasicArrow(ctx, origX, origY, x, y); + ctx.stroke(); + ctx.strokeStyle = '#F5A542'; + ctx.lineWidth = 3; + _drawBasicArrow(ctx, origX, origY, x, y); + ctx.stroke(); +} + +// Draws a single arrow with no outline. +function _drawBasicArrow(ctx, origX, origY, x, y) { + var l = 10; // length of arrow head + var angle = Math.atan2(y - origY, x - origX); + ctx.moveTo(x, y); + ctx.lineTo(x - l*Math.cos(angle - Math.PI/5), y - l*Math.sin(angle - Math.PI/5)); + ctx.moveTo(x, y); + ctx.lineTo(x - l*Math.cos(angle + Math.PI/5), y - l*Math.sin(angle + Math.PI/5)); + ctx.moveTo(origX, origY); + ctx.lineTo(x, y); +} + +module.exports = ViewLinker; diff --git a/app/client/components/ViewPane.ts b/app/client/components/ViewPane.ts new file mode 100644 index 00000000..8496d5aa --- /dev/null +++ b/app/client/components/ViewPane.ts @@ -0,0 +1,6 @@ +// This module is unused except to group some modules for a webpack bundle. +// TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements. + +import * as FieldConfigTab from 'app/client/components/FieldConfigTab'; +import * as ViewConfigTab from 'app/client/components/ViewConfigTab'; +export {FieldConfigTab, ViewConfigTab}; diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js new file mode 100644 index 00000000..db69269b --- /dev/null +++ b/app/client/components/commandList.js @@ -0,0 +1,404 @@ +// The top-level groups, and the ordering within them are for user-facing documentation. +exports.groups = [{ + group: 'General', + commands: [ + { + name: 'shortcuts', + keys: ['F1', 'Mod+/'], + desc: 'Display shortcuts pane' + }, { + name: 'help', + keys: [], + desc: 'Display Grist documentation' + }, { + name: 'undo', + keys: ['Mod+z'], + desc: 'Undo last action' + }, { + name: 'redo', + keys: ['Mod+Shift+Z', 'Ctrl+y' ], + desc: 'Redo last action' + }, { + name: 'accept', + keys: ['Enter'], + desc: null, // Accept the action of the dialog box + }, { + name: 'cancel', + keys: ['Escape'], + desc: null, // Cancel the action of the dialog box + }, { + name: 'find', + keys: ['Mod+f'], + desc: 'Find', + }, { + name: 'findNext', + keys: ['Mod+g'], + desc: 'Find next occurrence', + }, { + name: 'findPrev', + keys: ['Mod+Shift+G'], + desc: 'Find previous occurrence', + }, { + // Without this, when focus in on Clipboard, this shortcut would only move the cursor. + name: 'historyBack', + keys: ['Mod+Left'], + desc: null, // Move back in history, same as clicking the Back button + }, { + // Without this, when focus in on Clipboard, this shortcut would only move the cursor. + name: 'historyForward', + keys: ['Mod+Right'], + desc: null, // Move forward in history, same as clicking the Forward button + }, { + name: 'reloadPlugins', + keys: ['Mod+Alt+P'], + desc: null, // reload plugins + } + + ], +}, { + group: 'Menu shortcuts', + commands: [ + { + name: 'closeActiveMenu', + keys: ['Esc'], + desc: null, // Shortcut to close active menu + }, + { + name: 'filterMenuOpen', + keys: [], + desc: 'Shortcut to open filter menu' + }, + { + name: 'docTabOpen', + keys: [], + desc: 'Shortcut to open document tab' + }, + { + name: 'viewTabOpen', + keys: [], + desc: 'Shortcut to open view tab' + }, + { + name: 'fieldTabOpen', + keys: [], + desc: 'Shortcut to open field tab' + }, + { + name: 'sortFilterTabOpen', + keys: [], + desc: 'Shortcut to sort & filter tab' + }, + { + name: 'dataSelectionTabOpen', + keys: [], + desc: 'Shortcut to data selection tab' + } + ] +}, { + group: 'Navigation', + commands: [ + { + name: 'cursorDown', + keys: ['Down'], + desc: 'Move downward to next record or field' + }, { + name: 'cursorUp', + keys: ['Up'], + desc: 'Move upward to previous record or field' + }, { + name: 'cursorRight', + keys: ['Right'], + desc: 'Move right to the next field' + }, { + name: 'cursorLeft', + keys: ['Left'], + desc: 'Move left to the previous field' + }, { + name: 'nextField', + keys: ['Tab'], + desc: 'Move to the next field, saving changes if editing a value' + }, { + name: 'prevField', + keys: ['Shift+Tab'], + desc: 'Move to the previous field, saving changes if editing a value' + }, { + name: 'pageDown', + keys: ['PageDown'], + desc: 'Move down one page of records, or to next record in a card list' + }, { + name: 'pageUp', + keys: ['PageUp'], + desc: 'Move up one page of records, or to previous record in a card list' + }, { + name: 'moveToFirstRecord', + keys: ['Mod+Up'], + desc: 'Move up to the first record', + }, { + name: 'moveToLastRecord', + keys: ['Mod+Down'], + desc: 'Move down to the last record', + }, { + name: 'moveToFirstField', + keys: ['Home'], + desc: 'Move to the first field or the beginning of a row' + }, { + name: 'moveToLastField', + keys: ['End'], + desc: 'Move to the last field or the end of a row' + }, { + // no longer used + name: 'skipDown', + keys: [], + desc: 'Move downward five records' + }, { + // no longer used + name: 'skipUp', + keys: [], + desc: 'Move upward five records' + }, { + name: 'setCursor', + keys: [], + desc: 'Moves the cursor to the correct location' + }, { + name: 'openDocumentList', + keys: [], + desc: 'Opens document list' + }, { + name: 'nextPage', + keys: ['Alt+Down'], + desc: 'Open next page' + }, { + name: 'prevPage', + keys: ['Alt+Up'], + desc: 'Open previous page' + }, { + name: 'nextSection', + keys: ['Mod+o'], + desc: 'Activate next page widget', + }, { + name: 'prevSection', + keys: ['Mod+Shift+O'], + desc: 'Activate previous page widget', + } + ], +}, { + group: 'Selection', + commands: [ + { + name: 'shiftDown', + keys: ['Shift+Down'], + desc: 'Adds the element below the cursor to the selected range' + }, { + name: 'shiftUp', + keys: ['Shift+Up'], + desc: 'Adds the element above the cursor to the selected range' + }, { + name: 'shiftRight', + keys: ['Shift+Right'], + desc: 'Adds the element to the right of the cursor to the selected range' + }, { + name: 'shiftLeft', + keys: ['Shift+Left'], + desc: 'Adds the element to the left of the cursor to the selected range' + }, { + name: 'selectAll', + keys: ['Mod+A'], + desc: 'Selects all currently displayed cells' + }, { + name: 'copyLink', + keys: ['Mod+Shift+A'], + desc: 'Copy anchor link' + } + ], +}, { + group: 'Editing', + commands: [ + { + name: 'editField', + keys: ['Enter', 'F2'], + desc: 'Start editing the currently-selected cell' + }, { + name: 'fieldEditSave', + keys: ['Enter'], + desc: 'Finish editing a cell, saving the value' + }, { + name: 'fieldEditSaveHere', + keys: [], + desc: 'Finish editing a cell and save without moving to next record', + }, { + name: 'fieldEditCancel', + keys: ['Escape'], + desc: 'Discard changes to a cell value' + }, { + name: 'copy', + keys: [], + desc: 'Copy current selection to clipboard' + }, { + name: 'cut', + keys: [], + desc: 'Cut current selection to clipboard' + }, { + name: 'paste', + keys: [], + desc: 'Paste clipboard contents at cursor' + }, { + name: 'fillSelectionDown', + keys: ['Mod+D'], + desc: 'Fills current selection with the contents of the top row in the selection' + }, { + name: 'clearValues', + keys: ['Backspace', 'Del'], + desc: 'Clears the currently selected cells' + }, { + name: 'input', + keys: [], + desc: 'Enter text into currently-selected cell and start editing' + }, { + name: 'editLabel', + keys: [], + desc: 'Edit label of the currently-selected field' + }, { + name: 'editLayout', + keys: [], + desc: 'Edit record layout' + }, { + name: 'toggleCheckbox', + keys: ['Enter', 'Space'], + desc: 'Toggles the value of checkbox cells' + }, { + name: 'historyPrevious', + keys: ['Up'], + desc: null, // Fetches the previous command from the history list, moving back in the list + }, { + name: 'historyNext', + keys: ['Down'], + desc: null, // Fetches the next command from the history list, moving forward in the list + }, { + name: 'makeFormula', + keys: ["="], + desc: 'When typed at the start of a cell, make this a formula column', + }, { + name: 'unmakeFormula', + keys: ['Backspace'], + desc: null, // Undoes turning of column into a formula column, when pressed at start of a cell + }, { + name: 'insertCurrentDate', + keys: ['Mod+;'], + desc: 'Insert the current date', + }, { + name: 'insertCurrentDateTime', + keys: ['Mod+Shift+;'], + desc: 'Insert the current date and time', + }, { + name: 'datepickerFocus', + keys: ['Up', 'Down'], + desc: null, // While editing a date cell, switch keyboard focus to the datepicker + } + ], +}, { + group: 'Data manipulation', + commands: [ + { + name: 'insertRecordBefore', + keys: ['Mod+Shift+='], + desc: 'Insert a new record, before the currently selected one in an unsorted table' + }, { + name: 'insertRecordAfter', + keys: ['Mod+='], + desc: 'Insert a new record, after the currently selected one in an unsorted table', + }, { + name: 'deleteRecords', + keys: ['Mod+-'], + desc: 'Delete the currently selected record' + }, { + name: 'insertFieldBefore', + keys: ['Alt+Shift+='], + desc: 'Insert a new column, before the currently selected one' + }, { + name: 'insertFieldAfter', + keys: ['Alt+='], + desc: 'Insert a new column, after the currently selected one' + }, { + name: 'renameField', + keys: ['Ctrl+m'], + desc: 'Rename the currently selected column' + }, { + name: 'hideField', + keys: ['Alt+Shift+-'], + desc: 'Hide the currently selected column' + }, { + name: 'deleteFields', + keys: ['Alt+-'], + desc: 'Delete the currently selected columns' + }, { + name: 'addSection', + keys: [], + desc: 'Add a new viewsection to the currently active view' + }, { + name: 'deleteSection', + keys: [], + desc: 'Delete the currently active viewsection' + } + ], +}, { + group: 'Sorting', + commands: [ + { + name: 'sortAsc', + keys: [], + desc: 'Sort the view data by the currently selected field in ascending order' + }, { + name: 'sortDesc', + keys: [], + desc: 'Sort the view data by the currently selected field in descending order' + }, { + name: 'addSortAsc', + keys: [], + desc: 'Adds the currently selected column(ascending) to the current view\'s sort spec' + }, { + name: 'addSortDesc', + keys: [], + desc: 'Adds the currently selected column(descending) to the current view\'s sort spec' + }, + + ], +}, { + group: 'Linking', + commands: [ + { + name: 'enterLinkMode', + keys: [], + desc: 'Enters section linking mode in the current view' + }, { + name: 'exitLinkMode', + keys: [], + desc: 'Exits section linking mode in the current view' + }, { + name: 'saveLinks', + keys: [], + desc: 'Saves the sections links in the current view' + }, { + name: 'revertLinks', + keys: [], + desc: 'Reverts the sections links to the saved links the current view' + }, { + name: 'clearLinks', + keys: [], + desc: 'Clears the section links in the current view' + }, { + name: 'clearSectionLinks', + keys: [], + desc: 'Clears the section links in the current viewsection' + } + ], +}, { + group: 'Transforming', + commands: [ + { + // TODO: Use AceEditor internal save command instead of custom transform save command + name: 'transformUpdate', + keys: ['Shift+Enter'], + desc: null // Updates the transform formula + } + ], +}]; diff --git a/app/client/components/commands.css b/app/client/components/commands.css new file mode 100644 index 00000000..26c8364d --- /dev/null +++ b/app/client/components/commands.css @@ -0,0 +1,53 @@ +.shortcut_keys { + display: inline-block; +} + +.context-menu-item .shortcut_keys { + font-size: 1.2rem; +} + +.shortcut_key_image { + display: inline-block; + border-left: 2px solid #eee; + border-top: 2px solid #eee; + border-right: 2px solid #aaa; + border-bottom: 2px solid #aaa; + box-shadow: inset 0px 0px 0px 1px #fff, inset 3px 1px 0.5rem 2px #eee; + border-radius: 3px; + margin: 1px 0.2rem; + padding: 1px 6px; + font-size: 0.9em; + color: #666; + background-color: white; +} + +.shortcut_key_image.pressed { + border-left: 2px solid #aba; + border-top: 2px solid #aba; + border-right: 2px solid #efe; + border-bottom: 2px solid #efe; + box-shadow: inset 0px 0px 0px 1px #efe, inset 3px 1px 0.5rem 2px #efe; +} + +.shortcut_key_image.highlight { + border-left: 2px solid #cfc; + border-top: 2px solid #cfc; + border-right: 2px solid #8b8; + border-bottom: 2px solid #8b8; + box-shadow: inset 0px 0px 0px 1px #bfb, inset 3px 1px 0.5rem 2px #bfb; +} + +.g-help .shortcut_key_image { + display: inline-block; + border-left: 2px solid #777; + border-top: 2px solid #777; + border-right: 2px solid #444; + border-bottom: 2px solid #444; + box-shadow: inset 0px 0px 0px 1px #555, inset -3px -1px 0.5rem 2px #777; + border-radius: 3px; + margin: 1px 0.2rem; + padding: 1px 6px; + font-size: 0.9em; + color: #cf0; + background-color: #555; +} diff --git a/app/client/components/commands.js b/app/client/components/commands.js new file mode 100644 index 00000000..5745427c --- /dev/null +++ b/app/client/components/commands.js @@ -0,0 +1,330 @@ +/** + * Commands are invoked by the user via keyboard shortcuts or mouse clicks, for example, to move + * the cursor or to delete the selected records. + * + * This module provides APIs for other components to implement groups of commands. Any given + * command may be implemented by different components, but at most one implementation of any + * command is active at any time. + */ + + +/* global navigator */ + +var _ = require('underscore'); +var ko = require('knockout'); +var Mousetrap = require('../lib/Mousetrap'); +var dom = require('../lib/dom'); +var gutil = require('app/common/gutil'); +var dispose = require('../lib/dispose'); +var commandList = require('./commandList'); +require('../lib/koUtil'); // for subscribeInit + +var G = require('../lib/browserGlobals').get('window'); + +// Same logic as used by mousetrap to map 'Mod' key to platform-specific key. +var isMac = (typeof navigator !== 'undefined' && navigator && + /Mac|iPod|iPhone|iPad/.test(navigator.platform)); + +/** + * Globally-exposed map of command names to Command objects. E.g. typing "cmd.cursorDown.run()" in + * the browser console should move the cursor down as long as it makes sense in the currently + * shown view. If the command is inactive, its run() function is a no-op. + * + * See also Command object below. + */ +var allCommands = {}; +exports.allCommands = allCommands; + +/** + * This is an internal variable, mapping key combinations to the stack of CommandGroups which + * include them (see also CommandGroup.knownKeys). It's used for deciding which CommandGroup to + * use when different Commands use the same key. + */ +var _allKeys = {}; + +/** + * Populate allCommands from those provided, or listed in commandList.js. Also populates the + * globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in + * the browser console will run allCommands.cursorDown.run(). + */ +function init(optCommandGroups) { + var commandGroups = optCommandGroups || commandList.groups; + + // Clear out the objects holding the global state. + Object.keys(allCommands).forEach(function(c) { + delete allCommands[c]; + }); + Object.keys(_allKeys).forEach(function(k) { + delete _allKeys[k]; + }); + + commandGroups.forEach(function(commandGroup) { + commandGroup.commands.forEach(function(c) { + if (allCommands[c.name]) { + console.error("Ignoring duplicate command %s in commandList", c.name); + } else { + allCommands[c.name] = new Command(c.name, c.desc, c.keys); + } + }); + }); + + // Define the browser console interface. + G.window.cmd = {}; + _.each(allCommands, function(cmd, name) { + Object.defineProperty(G.window.cmd, name, {get: cmd.run}); + }); +} +exports.init = init; + +//---------------------------------------------------------------------- + +const KEY_MAP_MAC = { + Mod: '⌘', + Alt: '⌥', + Shift: '⇧', + Ctrl: '⌃', + Left: '←', + Right: '→', + Up: '↑', + Down: '↓', +}; + +const KEY_MAP_WIN = { + Mod: 'Ctrl', + Left: '←', + Right: '→', + Up: '↑', + Down: '↓', +}; + +function getHumanKey(key, isMac) { + const keyMap = isMac ? KEY_MAP_MAC : KEY_MAP_WIN; + let keys = key.split('+').map(s => s.trim()); + keys = keys.map(k => { + if (k in keyMap) { return keyMap[k]; } + if (k.length === 1) { return k.toUpperCase(); } + return k; + }); + return keys.join( isMac ? '' : ' + '); +} + +/** + * Command represents a single command. It is exposed via the `allCommands` map. + * @property {String} name: The name of the command, same as the key into the `allCommands` map. + * @property {String} desc: The description of the command. + * @property {Array} keys: The array of keyboard shortcuts for the command. + * @property {Function} run: A bound function that will run the currently active implementation. + * @property {Observable} isActive: Knockout observable for whether this command is active. + */ +function Command(name, desc, keys) { + this.name = name; + this.desc = desc; + this.humanKeys = keys.map(key => getHumanKey(key, isMac)); + this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); }); + this.isActive = ko.observable(false); + this._implGroupStack = []; + this._activeFunc = _.noop; // The function to run when this command is invoked. + + // Let .run bind the Command object, so that it can be used as a stand-alone callback. + this.run = this._run.bind(this); +} +exports.Command = Command; + +Command.prototype._run = function() { + return this._activeFunc.apply(null, arguments); +}; + +/** + * Returns the text description for the command, including the keyboard shortcuts. + */ +Command.prototype.getDesc = function() { + var desc = this.desc; + if (this.humanKeys.length) { + desc += " (" + this.humanKeys.join(", ") + ")"; + } + return desc; +}; + +/** + * Returns DOM for the keyboard shortcuts, wrapped in cute boxes that look like keyboard keys. + */ +Command.prototype.getKeysDom = function() { + return dom('span.shortcut_keys', + this.humanKeys.map(key => dom('span.shortcut_key_image', key)) + ); +}; + +/** + * Adds a CommandGroup that implements this Command to the top of the stack of groups. + */ +Command.prototype._addGroup = function(cmdGroup) { + this._implGroupStack.push(cmdGroup); + this._updateActive(); +}; + +/** + * Removes a CommandGroup from the stack of groups implementing this Command. + */ +Command.prototype._removeGroup = function(cmdGroup) { + gutil.arrayRemove(this._implGroupStack, cmdGroup); + this._updateActive(); +}; + +/** + * Updates the command's state to reflect the currently active group, if any. + */ +Command.prototype._updateActive = function() { + if (this._implGroupStack.length > 0) { + this.isActive(true); + this._activeFunc = _.last(this._implGroupStack).commands[this.name]; + } else { + this.isActive(false); + this._activeFunc = _.noop; + } + + // Now bind or unbind the affected key combinations. + this.keys.forEach(function(key) { + var keyGroups = _allKeys[key]; + if (keyGroups && keyGroups.length > 0) { + var commandGroup = _.last(keyGroups); + // Command name might be different from this.name in case we are deactivating a command, and + // the previous meaning of the key points to a different command. + var commandName = commandGroup.knownKeys[key]; + Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName])); + } else { + Mousetrap.unbind(key); + } + }); +}; + +/** + * Helper for mousetrap callbacks, which returns a version of the callback that by default stops + * the propagation of the keyboard event (unless the callback returns a true value). + */ +function wrapKeyCallback(callback) { + return function() { + return callback.apply(null, arguments) || false; + }; +} + +//---------------------------------------------------------------------- + +/** + * CommandGroup is the way for other components to provide implementations for a group of + * commands. Note that CommandGroups are stacked, with groups activated later having priority over + * groups activated earlier. + * @param {String->Function} commands: The map of command names to implementations. + * @param {Object} context: "this" context with which to invoke implementation functions. + * @param {Boolean|Observable} activate: Whether to activate this group immediately, false if + * omitted. This may be an Observable. + */ +function CommandGroup(commands, context, activate) { + // Keep only valid commands, so that we don't have to check for validity elsewhere, and bind + // each to the passed-in context object. + this.commands = {}; + this.isActive = false; + + var name; + for (name in commands) { + if (allCommands[name]) { + this.commands[name] = commands[name].bind(context); + } else { + console.warn("Ignoring unknown command %s", name); + } + } + + // Map recognized key combinations to the corresponding command names. + this.knownKeys = {}; + for (name in this.commands) { + var keys = allCommands[name].keys; + for (var i = 0; i < keys.length; i++) { + this.knownKeys[keys[i]] = name; + } + } + + // On disposal, remove the CommandGroup from all the commands and keys. + this.autoDisposeCallback(this._removeGroup); + + // Finally, set the activatation status of the command group, subscribing if an observable. + if (ko.isObservable(activate)) { + this.autoDispose(activate.subscribeInit(this.activate, this)); + } else { + this.activate(activate); + } +} +exports.CommandGroup = CommandGroup; +dispose.makeDisposable(CommandGroup); + +/** + * Just a shorthand for CommandGroup.create constructor. + */ +function createGroup(commands, context, activate) { + return CommandGroup.create(commands, context, activate); +} +exports.createGroup = createGroup; + + +/** + * Activate or deactivate this implementation group. + */ +CommandGroup.prototype.activate = function(yesNo) { + if (yesNo) { + this._addGroup(); + } else { + this._removeGroup(); + } +}; + +CommandGroup.prototype._addGroup = function() { + if (!this.isActive) { + this.isActive = true; + // Add this CommandGroup to each key combination that it recognizes. + for (var key in this.knownKeys) { + (_allKeys[key] || (_allKeys[key] = [])).push(this); + } + // Add this CommandGroup to each command that it implements. + for (var name in this.commands) { + allCommands[name]._addGroup(this); + } + } +}; + +CommandGroup.prototype._removeGroup = function() { + if (this.isActive) { + // On disposal, remove the CommandGroup from all the commands and keys. + for (var key in this.knownKeys) { + gutil.arrayRemove(_allKeys[key], this); + } + for (var name in this.commands) { + allCommands[name]._removeGroup(this); + } + this.isActive = false; + } +}; + +/** + * Attach this CommandGroup to a DOM element, to allow it to accept key events, limiting them to + * this group only. This is useful for inputs and textareas, where only a limited set of keyboard + * shortcuts should be applicable and where by default mousetrap ignores shortcuts completely. + * + * See also stopCallback in app/client/lib/Mousetrap.js. + */ +CommandGroup.prototype.attach = dom.inlinable(function(elem) { + ko.utils.domData.set(elem, 'mousetrapCommandGroup', this); +}); + +//---------------------------------------------------------------------- + +/** + * Tie the button to an command listed in commandList.js, triggering the callback from the + * currently active CommandLayer (if any), and showing a description and keyboard shortcuts in its + * tooltip. + * + * You may use this inline while building dom, as in + * dom('button', commands.setButtomCommand(dom, 'command')) + */ +exports.setButtonCommand = dom.inlinable(function(elem, commandName) { + var cmd = allCommands[commandName]; + elem.setAttribute('title', cmd.getDesc()); + dom.on(elem, 'click', cmd.run); +}); diff --git a/app/client/components/duplicatePage.ts b/app/client/components/duplicatePage.ts new file mode 100644 index 00000000..5a6762f7 --- /dev/null +++ b/app/client/components/duplicatePage.ts @@ -0,0 +1,179 @@ +import { GristDoc } from 'app/client/components/GristDoc'; +import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; +import { cssField, cssInput, cssLabel} from 'app/client/ui/MakeCopyMenu'; +import { IPageWidget, toPageWidget } from 'app/client/ui/PageWidgetPicker'; +import { confirmModal } from 'app/client/ui2018/modals'; +import { BulkColValues, RowRecord, UserAction } from 'app/common/DocActions'; +import { arrayRepeat } from 'app/common/gutil'; +import { schema } from 'app/common/schema'; +import { dom } from 'grainjs'; +import cloneDeepWith = require('lodash/cloneDeepWith'); +import flatten = require('lodash/flatten'); +import forEach = require('lodash/forEach'); +import zip = require('lodash/zip'); +import zipObject = require('lodash/zipObject'); + +// Duplicate page with pageId. Starts by prompting user for a new name. +export async function duplicatePage(gristDoc: GristDoc, pageId: number) { + const pagesTable = gristDoc.docModel.pages; + const pageName = pagesTable.rowModels[pageId].view.peek().name.peek(); + let inputEl: HTMLInputElement; + setTimeout(() => {inputEl.focus(); inputEl.select(); }, 100); + + confirmModal('Duplicate page', 'Save', () => makeDuplicate(gristDoc, pageId, inputEl.value), ( + dom('div', [ + "Enter name for the new page. ", + "Note that this does not copy data, ", + "but creates another view of the same data. ", + cssField( + cssLabel("Name"), + inputEl = cssInput({value: pageName + ' (copy)'}), + ) + ]) + )); +} + +async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: string = '') { + const sourceView = gristDoc.docModel.pages.rowModels[pageId].view.peek(); + pageName = pageName || `${sourceView.name.peek()} (copy)`; + const viewSections = sourceView.viewSections.peek().peek(); + let viewRef = 0; + await gristDoc.docData.bundleActions( + `Duplicate page ${pageName}`, + async () => { + // create new view and new sections + const results = await createNewViewSections(gristDoc.docData, viewSections); + viewRef = results[0].viewRef; + + // give it a better name + await gristDoc.docModel.views.rowModels[viewRef].name.saveOnly(pageName); + + // create a map from source to target section ids + const viewSectionIdMap = zipObject( + viewSections.map(vs => vs.getRowId()), + results.map(res => res.sectionRef) + ) as {[id: number]: number}; + + // update layout spec + const viewLayoutSpec = patchLayoutSpec(sourceView.layoutSpecObj.peek(), viewSectionIdMap); + await gristDoc.docData.sendAction( + ['UpdateRecord', '_grist_Views', viewRef, { layoutSpec: JSON.stringify(viewLayoutSpec)}] + ); + + // update the view fields + const destViewSections = viewSections.map((vs) => ( + gristDoc.docModel.viewSections.rowModels[viewSectionIdMap[vs.getRowId()]] + )); + const newViewFieldIds = await updateViewFields(gristDoc, destViewSections, viewSections); + + // create map for mapping from a src field's id to its corresponding dest field's id + const viewFieldsIdMap = zipObject( + flatten(viewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId()))), + flatten(newViewFieldIds)) as {[id: number]: number}; + + // update the view sections + await updateViewSections(gristDoc, destViewSections, viewSections, viewFieldsIdMap, viewSectionIdMap); + }); + + // Give copy focus + await gristDoc.openDocPage(viewRef); +} + +/** + * Update all of destViewSections with srcViewSections, use fieldsMap to patch the section layout + * (for detail/cardlist sections), use viewSectionMap to patch the sections ids for linking. + */ +async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSectionRec[], + srcViewSections: ViewSectionRec[], fieldsMap: {[id: number]: number}, + viewSectionMap: {[id: number]: number}) { + + // collect all the records for the src view sections + const records: RowRecord[] = []; + for (const srcViewSection of srcViewSections) { + const viewSectionLayoutSpec = patchLayoutSpec(srcViewSection.layoutSpecObj.peek(), fieldsMap); + const record = gristDoc.docData.getTable('_grist_Views_section')!.getRecord(srcViewSection.getRowId())!; + records.push({ + ...record, + layoutSpec: JSON.stringify(viewSectionLayoutSpec), + linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()], + }); + } + + // transpose data + const sectionsInfo = {} as BulkColValues; + forEach(records[0], (val, key) => sectionsInfo[key] = records.map(rec => rec[key])); + + // ditch column ids and parentId + delete sectionsInfo.id; + delete sectionsInfo.parentId; + + // send action + const rowIds = destViewSections.map((vs) => vs.getRowId()); + await gristDoc.docData.sendAction(['BulkUpdateRecord', '_grist_Views_section', rowIds, sectionsInfo]); +} + +async function updateViewFields(gristDoc: GristDoc, destViewSections: ViewSectionRec[], + srcViewSections: ViewSectionRec[]) { + const actions: UserAction[] = []; + const docData = gristDoc.docData; + + // First, remove all existing fields. Needed because `CreateViewSections` adds some by default. + const toRemove = flatten(destViewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId()))); + actions.push(['BulkRemoveRecord', '_grist_Views_section_field', toRemove]); + + // collect all the fields to add + const fieldsToAdd: RowRecord[] = []; + for (const [destViewSection, srcViewSection] of zip(destViewSections, srcViewSections)) { + const srcViewFields: ViewFieldRec[] = srcViewSection!.viewFields.peek().peek(); + const parentId = destViewSection!.getRowId(); + for (const field of srcViewFields) { + const record = docData.getTable('_grist_Views_section_field')!.getRecord(field.getRowId())!; + fieldsToAdd.push({...record, parentId}); + } + } + + // transpose data + const fieldsInfo = {} as BulkColValues; + forEach(schema._grist_Views_section_field, (val, key) => fieldsInfo[key] = fieldsToAdd.map(rec => rec[key])); + const rowIds = arrayRepeat(fieldsInfo.parentId.length, null); + actions.push(['BulkAddRecord', '_grist_Views_section_field', rowIds, fieldsInfo]); + + const results = await gristDoc.docData.sendActions(actions); + return results[1]; +} + +/** + * Create a new view containing all of the viewSections. Note that it doesn't copy view fields, for + * which you can use `updateViewFields`. + */ +async function createNewViewSections(docData: GristDoc['docData'], viewSections: ViewSectionRec[]) { + const [first, ...rest] = viewSections.map(toPageWidget); + + // Passing a viewId of 0 will create a new view. + const firstResult = await docData.sendAction(newViewSectionAction(first, 0)); + + const otherResult = await docData.sendActions( + // other view section are added to the newly created view + rest.map((widget) => newViewSectionAction(widget, firstResult.viewRef)) + ); + return [firstResult, ...otherResult]; +} + +// Helper to create an action that add widget to the view with viewId. +function newViewSectionAction(widget: IPageWidget, viewId: number) { + return ['CreateViewSection', widget.table, viewId, widget.type, widget.summarize ? widget.columns : null]; +} + +/** + * Replaces each `leaf` id in layoutSpec by its corresponding id in mapIds. Leave unchanged if id is + * missing from mapIds. + */ +export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) { + return cloneDeepWith(layoutSpec, (val) => { + if (typeof val === 'object') { + if (mapIds[val.leaf]) { + return {...val, leaf: mapIds[val.leaf]}; + } + } + }); +} diff --git a/app/client/components/viewCommon.css b/app/client/components/viewCommon.css new file mode 100644 index 00000000..44c48c9a --- /dev/null +++ b/app/client/components/viewCommon.css @@ -0,0 +1,180 @@ +.record { + display: -webkit-flex; + display: flex; + position: relative; + box-sizing: border-box; + -moz-box-sizing: border-box; + + border-width: 0px; + border-style: none; + border-color: var(--grist-color-dark-grey); + border-left-style: solid; /* left border, against rownumbers div, always on */ + border-bottom-width: 1px; /* style: none, set by record-hlines*/ +} + +.record.record-hlines { /* Overwrites style, width set on element */ + border-bottom-style: solid; +} + +.record.record-zebra.record-even { + background-color: #f8f8f8; +} + +.record.record-add { + background-color: #f6f6ff !important; /* important to win over zebra stripes */ +} + +.field { + position: relative; + height: 100%; + -webkit-flex: none; + flex: none; + min-height: 22px; + white-space: pre; + /* make border exist always so content doesn't shift on v-gridline toggle */ + border: 0px solid transparent; /* width set by js, border exists but is transparent */ +} + +.record-vlines > .field { + border-right-color: var(--grist-color-dark-grey); /* set border visibility */ +} + +.field.scissors { + outline: 2px dashed var(--grist-color-cursor); +} + +.field.selected { + background-color: var(--grist-color-selection); +} + +.field_clip { + padding: 3px 3px 0px 3px; + font-family: var(--grist-font-family-data); + line-height: 18px; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + height: 100%; +} + +.field_clip.invalid { + background-color: #ffb6c1; +} + +.field_clip.invalid:empty { + background-color: unset; +} + +.field_clip.field-error-P { + color: #B0B0B0; + background-color: unset; +} + +.field_clip.field-error-U { + color: #6363a2; + background-color: unset; +} + +/* Insert a zero-width space into each cell, to size cells to at least one line of text. */ +.field_clip:empty::before { content: '\200B'; } + +@media not print { +.selected_cursor { + position: absolute; + left: 0px; + top: 0px; + width: 100%; + height: 100%; + /* one pixel outline around the cell, and one inside the cell */ + outline: 1px solid var(--grist-color-inactive-cursor); + box-shadow: inset 0 0 0 1px var(--grist-color-inactive-cursor); + pointer-events: none; +} + +.active_cursor { + outline: 1px solid var(--grist-color-cursor); + box-shadow: inset 0 0 0 1px var(--grist-color-cursor); +} +} + +/* These classes are used to flash the cursor to indicate that editing in a cell is disabled. */ +.cursor_read_only { + outline: 1px solid #ff9a00; + box-shadow: inset 0 0 0 1px #ff9a00; +} + +.cursor_read_only_fade { + outline-color: var(--grist-color-cursor); + box-shadow: inset 0 0 0 1px var(--grist-color-cursor); + transition: outline-color 0.5s ease-in, box-shadow 0.5s ease-in; +} + +.cursor_read_only_lock { + top: 0px; + height: 100%; + padding: 0 4px; + line-height: inherit; + background-color: #ff9a00; + color: white; + opacity: 1; +} + +.cursor_read_only_fade > .cursor_read_only_lock { + opacity: 0; + transition: opacity 0.5s ease-in; +} + +.column_name { + background-color: var(--grist-color-light-grey); + text-align: center; + cursor: pointer; + /* Column headers always show vertical gridlines, to make it clear how to resize them */ + border-right-color: var(--grist-color-dark-grey); +} + +.column_name.selected { + background-color: var(--grist-color-medium-grey-opaque); +} + +.gridview_data_row_num.selected { + background-color: var(--grist-color-medium-grey-opaque); +} + +.gridview_data_row_info.linked_dst::before { + position: absolute; + content: '\25B8'; + text-align: left; + left: 7px; +} + +.text_wrapping { + word-break: break-word; + white-space: pre-wrap; +} + +.diff-change .diff-parent, .diff-change .diff-remote { + display: inline-block; + width: 50%; +} + +.diff-conflict .diff-parent, .diff-conflict .diff-local, .diff-conflict .diff-remote { + display: inline-block; + width: 33.33%; +} + +.diff-local { + background-color: #dfdfff; +} + +.diff-parent { + background-color: #ffdfdf; + text-decoration: line-through; +} + +.diff-remote { + background-color: #afffaf; +} + +.diff-common { + color: #555; +} diff --git a/app/client/components/viewCommon.js b/app/client/components/viewCommon.js new file mode 100644 index 00000000..ac076035 --- /dev/null +++ b/app/client/components/viewCommon.js @@ -0,0 +1,69 @@ +/* global $ */ + +var koDom = require('../lib/koDom'); + +/** + * This adds `.isFlex` option to JQuery's $.ui.resizable to make it work better with flexbox. + * Specifically, when resizing to the left, JQuery adjusts both `width` and `left` properties. If + * the element is part of a flexbox, it's wrong to adjust `left`. This widget adds `.isFlex` + * option: when set to true, the `left` (also `top`) adjustments get ignored. + */ +var _respectSize = $.ui.resizable.prototype._respectSize; +$.ui.resizable.prototype._respectSize = function() { + var data = _respectSize.apply(this, arguments); + if (this.options.isFlex) { + console.log("Ignoring left, top"); + data.left = data.top = undefined; + } + return data; +}; + +/** + * When used as an argument to dom() function, makes the containing element resizable, with the + * size written into the given observable. If the observable has a .save() method, it's called + * when the resize is complete (to save the new size to the server). + * @param {Object} options.enabled: An observable, a constant, or a function for a computed + * observable. The value is treated as a boolean, and determined whether resizable + * functionality is enabled. + * @param {String} options.handles: Same as for jqueryui's `resizable`, e.g. 'e' to resize right + * edge (east), 'w' to resize left edge (west). + * @param {Function} options.stop: Additional callback to call when resizing stops. + * @param {Boolean} options.isFlex: If true, will avoid changing 'left' when resizing the left edge. + * @param {Number} options.minWidth: The minimum width the element can be resized to. + * Defaults to 10 (JQuery default). + */ +function makeResizable(widthObservable, options) { + options = options || {}; + function onEvent(e, ui) { + widthObservable(ui.size.width); + if (e.type === 'resizestop') { + if (options.stop) { + options.stop(e, ui); + } + if (widthObservable.save) { + widthObservable.save(); + } + } + } + + return function(elem) { + $(elem).resizable({ + handles: options.handles || 'e', + resize: onEvent, + stop: onEvent, + isFlex: options.isFlex, + minWidth: options.minWidth || 10 + }); + + if (options.hasOwnProperty('enabled')) { + koDom.setBinding(elem, options.enabled, function(elem, value) { + if (value) { + $(elem).resizable('enable'); + } else { + $(elem).resizable('disable').removeClass('ui-state-disabled'); + } + }); + } + }; +} +exports.makeResizable = makeResizable; diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts new file mode 100644 index 00000000..5d1b02b2 --- /dev/null +++ b/app/client/declarations.d.ts @@ -0,0 +1,352 @@ +declare module "app/client/components/AceEditor"; +declare module "app/client/components/Clipboard"; +declare module "app/client/components/CodeEditorPanel"; +declare module "app/client/components/DetailView"; +declare module "app/client/components/DocConfigTab"; +declare module "app/client/components/EmbedForm"; +declare module "app/client/components/FieldConfigTab"; +declare module "app/client/components/GridView"; +declare module "app/client/components/Layout"; +declare module "app/client/components/LayoutEditor"; +declare module "app/client/components/Login"; +declare module "app/client/components/ModalDialog"; +declare module "app/client/components/REPLTab"; +declare module "app/client/components/commandList"; +declare module "app/client/lib/Mousetrap"; +declare module "app/client/lib/browserGlobals"; +declare module "app/client/lib/dom"; +declare module "app/client/lib/koDom"; +declare module "app/client/lib/koForm"; +declare module "app/client/lib/koSession"; +declare module "app/client/models/DocListModel"; +declare module "app/client/widgets/UserType"; +declare module "app/client/widgets/UserTypeImpl"; + +// tslint:disable:max-classes-per-file + +declare module "app/client/components/Base" { + import {GristDoc} from 'app/client/components/GristDoc'; + + namespace Base { } + class Base { + public static setBaseFor(ctor: any): void; + constructor(gristDoc: GristDoc); + } + export = Base; +} + +declare module "app/client/components/BaseView" { + + import {Cursor, CursorPos} from 'app/client/components/Cursor'; + import {GristDoc} from 'app/client/components/GristDoc'; + import {Disposable} from 'app/client/lib/dispose'; + import {KoArray} from "app/client/lib/koArray"; + import * as BaseRowModel from "app/client/models/BaseRowModel"; + import {LazyArrayModel} from "app/client/models/DataTableModel"; + import * as DataTableModel from "app/client/models/DataTableModel"; + import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel"; + import {SortedRowSet} from 'app/client/models/rowset'; + import {DomArg} from 'grainjs'; + import {IOpenController} from 'popweasel'; + + namespace BaseView {} + class BaseView extends Disposable { + public viewSection: ViewSectionRec; + public viewPane: any; + public viewData: LazyArrayModel; + public gristDoc: GristDoc; + public cursor: Cursor; + public sortedRows: SortedRowSet; + public activeFieldBuilder: ko.Computed; + public disableEditing: ko.Computed; + public isTruncated: ko.Observable; + protected tableModel: DataTableModel; + + constructor(gristDoc: GristDoc, viewSectionModel: any); + public setCursorPos(cursorPos: CursorPos): void; + public createFilterMenu(ctl: IOpenController, field: ViewFieldRec): HTMLElement; + public buildTitleControls(): DomArg; + public getLoadingDonePromise(): Promise; + public onResize(): void; + } + export = BaseView; +} + +declare module "app/client/components/FieldConfigTab" { + import {GristDoc, TabContent} from 'app/client/components/GristDoc'; + import {Disposable} from 'app/client/lib/dispose'; + import {DomArg} from 'grainjs'; + + namespace FieldConfigTab {} + class FieldConfigTab extends Disposable { + public isForeignRefCol: ko.Computed; + public refSelect: any; + + constructor(options: {gristDoc: GristDoc, fieldBuilder: unknown, contentCallback: unknown}); + public buildConfigDomObj(): TabContent[]; + // TODO: these should be made private or renamed. + public _buildNameDom(): DomArg; + public _buildFormulaDom(): DomArg; + public _buildTransformDom(): DomArg; + public _buildFormatDom(): DomArg; + } + export = FieldConfigTab; +} + +declare module "app/client/components/ViewConfigTab" { + import {GristDoc, TabContent} from 'app/client/components/GristDoc'; + import {Disposable} from 'app/client/lib/dispose'; + import {KoArray} from "app/client/lib/koArray"; + import {ColumnRec, ViewRec, ViewSectionRec} from "app/client/models/DocModel"; + import {DomArg} from 'grainjs'; + + namespace ViewConfigTab { + interface ViewSectionData { + section: ViewSectionRec; + hiddenFields: KoArray; + } + } + + class ViewConfigTab extends Disposable { + constructor(options: {gristDoc: GristDoc, viewModel: ViewRec, skipDomBuild?: boolean}); + public buildConfigDomObj(): TabContent[]; + public buildSortDom(): DomArg; + // TODO: these should be made private or renamed. + public _buildSectionFieldsConfig(): DomArg; + public _buildNameDom(): DomArg; + public _buildSectionNameDom(): DomArg; + public _buildAdvancedSettingsDom(): DomArg; + public _buildDetailTypeDom(): DomArg; + public _buildFilterDom(): DomArg; + public _buildThemeDom(): DomArg; + public _buildGridStyleDom(): DomArg; + public _buildChartConfigDom(): DomArg; + public _buildLayoutDom(): DomArg; + public _buildLinkDom(): DomArg; + public _buildCustomTypeItems(): DomArg; + } + export = ViewConfigTab; +} + +declare module "app/client/components/ViewLinker" { + import {ViewRec} from "app/client/models/DocModel"; + + namespace ViewLinker { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class ViewLinkerNode { + public section: any; + public sectionRef: number; + public col: any; + public colRef: number; + public isValidLinkTo(node: ViewLinkerNode): boolean; + } + } + + class ViewLinker { + public static create(viewRec: ViewRec): ViewLinker; + public static createNodes(section: any|null, table: any): ViewLinker.ViewLinkerNode[]; + } + export = ViewLinker; +} + +declare module "app/client/components/commands" { + export class Command { + public name: string; + public desc: string; + public humanKeys: string[]; + public keys: string[]; + public run: () => any; + } + + export type CommandsGroup = any; + export const init: any; + export const allCommands: any; + export const createGroup: any; +} + +declare module "app/client/lib/tableUtil" { + + import {KoArray} from 'app/client/lib/koArray'; + import {ViewFieldRec} from 'app/client/models/DocModel'; + + function insertPositions(lowerPos: number|null, upperPos: number|null, numInserts: number): number[]; + function fieldInsertPositions(viewFields: KoArray, index: number, numInserts: number): number[]; +} + +declare module "app/client/models/BaseRowModel" { + import {Disposable} from 'app/client/lib/dispose'; + import * as TableModel from 'app/client/models/TableModel'; + import {ColValues} from 'app/common/DocActions'; + + namespace BaseRowModel {} + class BaseRowModel extends Disposable { + public id: ko.Computed; + public _index: ko.Observable; + public getRowId(): number; + public updateColValues(colValues: ColValues): Promise; + public _table: TableModel; + protected _rowId: number | 'new' | null; + protected _fields: string[]; + } + export = BaseRowModel; +} + +declare module "app/client/models/MetaRowModel" { + import * as BaseRowModel from "app/client/models/BaseRowModel"; + namespace MetaRowModel {} + class MetaRowModel extends BaseRowModel { + public _isDeleted: ko.Observable; + public events: { trigger: (key: string) => void }; + } + export = MetaRowModel; +} + +declare module "app/client/models/modelUtil" { + interface SaveInterface { + saveOnly(value: T): Promise; + save(): Promise; + setAndSave(value: T): Promise; + } + + type KoSaveableObservable = ko.Observable & SaveInterface; + type KoSaveableComputed = ko.Computed & SaveInterface; + + interface CustomComputed extends KoSaveableComputed { + isSaved: ko.Computed; + revert(): void; + } + + function addSaveInterface( + obs: ko.Observable|ko.Computed, + saveFunc: (value: T) => Promise): KoSaveableObservable; + + interface ObjObservable extends ko.Observable { + update(obj: T): void; + prop(propName: string): ko.Observable; + } + + interface SaveableObjObservable extends ko.Observable, SaveInterface { + update(obj: T): void; + prop(propName: string): KoSaveableObservable; + } + + function objObservable(obs: ko.Observable): ObjObservable; + function jsonObservable(obs: KoSaveableObservable, + modifierFunc?: any, optContext?: any): SaveableObjObservable; + function jsonObservable(obs: ko.Observable|ko.Computed, + modifierFunc?: any, optContext?: any): ObjObservable; + + function fieldWithDefault(fieldObs: KoSaveableObservable, defaultOrFunc: T | (() => T)): + KoSaveableObservable; + + function customValue(obs: KoSaveableObservable): CustomComputed; + + function savingComputed(options: { + read: () => T, + write: (setter: (obs: ko.Observable, val: T) => void, val: T) => void; + }): KoSaveableObservable; + + function customComputed(options: { + read: () => T, + save?: (val: T) => Promise; + }): CustomComputed; + + function setSaveValue(obs: KoSaveableObservable, val: T): Promise; +} + +declare module "app/client/models/TableModel" { + import {DocModel} from "app/client/models/DocModel"; + import {RowGrouping, RowSource} from "app/client/models/rowset"; + import {TableData} from "app/client/models/TableData"; + import {CellValue, UserAction} from "app/common/DocActions"; + + namespace TableModel {} + class TableModel extends RowSource { + public docModel: DocModel; + public tableData: TableData; + public isLoaded: ko.Observable; + + constructor(docModel: DocModel, tableData: TableData); + public fetch(force?: boolean): Promise; + public getAllRows(): ReadonlyArray; + public getRowGrouping(groupByCol: string): RowGrouping; + public sendTableActions(actions: UserAction[], optDesc?: string): Promise; + public sendTableAction(action: UserAction, optDesc?: string): Promise | undefined; + } + export = TableModel; +} + +declare module "app/client/models/MetaTableModel" { + import {KoArray} from "app/client/lib/koArray"; + import {DocModel} from "app/client/models/DocModel"; + import * as MetaRowModel from "app/client/models/MetaRowModel"; + import {RowSource} from "app/client/models/rowset"; + import {TableData} from "app/client/models/TableData"; + import * as TableModel from "app/client/models/TableModel"; + import {CellValue} from "app/common/DocActions"; + + namespace MetaTableModel {} + class MetaTableModel extends TableModel { + public rowModels: RowModel[]; + + constructor(docModel: DocModel, tableData: TableData, fields: string[], rowConstructor: (dm: DocModel) => void); + public loadData(): void; + public getRowModel(rowId: number, dependOnVersion?: boolean): RowModel; + public getEmptyRowModel(): RowModel; + public createFloatingRowModel(rowIdObs: ko.Observable|ko.Computed): RowModel; + public createRowGroupModel(groupValue: CellValue, options: {groupBy: string, sortBy: string}): KoArray; + public createAllRowsModel(sortColId: string): KoArray; + public _createRowSetModel(rowSource: RowSource, sortColId: string): KoArray; + } + export = MetaTableModel; +} + +declare module "app/client/models/DataTableModel" { + import {KoArray} from "app/client/lib/koArray"; + import * as BaseRowModel from "app/client/models/BaseRowModel"; + import {DocModel, TableRec} from "app/client/models/DocModel"; + import {TableQuerySets} from 'app/client/models/QuerySet'; + import {RowSource, SortedRowSet} from "app/client/models/rowset"; + import {TableData} from "app/client/models/TableData"; + import * as TableModel from "app/client/models/TableModel"; + import {CellValue} from "app/common/DocActions"; + + namespace DataTableModel { + interface LazyArrayModel extends KoArray { + getRowId(index: number): number; + getRowIndex(index: number): number; + getRowIndexWithSub(rowId: number): number; + getRowModel(rowId: number): T|undefined; + } + } + + class DataTableModel extends TableModel { + public tableMetaRow: TableRec; + public tableQuerySets: TableQuerySets; + + constructor(docModel: DocModel, tableData: TableData, tableMetaRow: TableRec); + public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any): + DataTableModel.LazyArrayModel; + public createFloatingRowModel(optRowModelClass: any): BaseRowModel; + } + export = DataTableModel; +} + +declare module "app/client/lib/koUtil" { + export interface ComputedWithKoUtils extends ko.Computed { + onlyNotifyUnequal(): this; + } + export interface ObservableWithKoUtils extends ko.Observable { + assign(value: unknown): this; + } + export function withKoUtils(computed: ko.Computed): ComputedWithKoUtils; + export function withKoUtils(computed: ko.Observable): ObservableWithKoUtils; + export function computedBuilder(callback: any, optContext: any): any; + export function observableWithDefault(obs: any, defaultOrFunc: any, optContext?: any): any; + export function computedAutoDispose(optionsOrReadFunc: any, target: any, options: any): any; +} + +// Used in browser check. Bowser does in fact have types, but not the bundled version +// with polyfills for old browsers. +declare module "bowser/bundled"; +declare module "randomcolor"; diff --git a/app/client/exposeModulesForTests.js b/app/client/exposeModulesForTests.js new file mode 100644 index 00000000..21bb26d5 --- /dev/null +++ b/app/client/exposeModulesForTests.js @@ -0,0 +1,13 @@ +/* global window */ + +// These modules are exposed for the sake of browser tests. +Object.assign(window.exposedModules, { + dom: require('./lib/dom'), + grainjs: require('grainjs'), + ko: require('knockout'), + moment: require('moment-timezone'), + Comm: require('./components/Comm'), + ProfileForm: require('./components/ProfileForm'), + _loadScript: require('./lib/loadScript'), + ConnectState: require('./models/ConnectState'), +}); diff --git a/app/client/index.ts b/app/client/index.ts deleted file mode 100644 index 8dfca7e3..00000000 --- a/app/client/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {PageContents, pagePanels} from 'app/client/ui/PagePanels'; -import {cssRootVars} from 'app/client/ui2018/cssVars'; -import {dom, observable, styled} from "grainjs"; - -function renderPage(): Element { - const leftPanelOpen = observable(true); - const page: PageContents = { - leftPanel: { - panelWidth: observable(240), - panelOpen: leftPanelOpen, - hideOpener: false, - header: testContent('LEFT HEADER'), - content: testContent('LEFT PANEL'), - }, - rightPanel: { - panelWidth: observable(240), - panelOpen: observable(true), - header: testContent('RIGHT HEADER'), - content: testContent('RIGHT PANEL'), - }, - headerMain: testContent('Header'), - contentMain: testContent('Welcome to a tiny bit of Grist'), - }; - return pagePanels(page); -} - -const testContent = styled('div', ` - padding: 5px; - text-align: center; - flex: 1 1 0px; -`); - -// Load icons.css, wait for it to load, then build the page. -dom.update(document.body, dom.cls(cssRootVars), renderPage()); diff --git a/app/client/lib/ACIndex.ts b/app/client/lib/ACIndex.ts new file mode 100644 index 00000000..ba1042c3 --- /dev/null +++ b/app/client/lib/ACIndex.ts @@ -0,0 +1,250 @@ +/** + * A search index for auto-complete suggestions. + * + * This implementation indexes words, and suggests items based on a best-match score, including + * amount of overlap and position of words. It searches case-insensitively and only at the start + * of words. E.g. searching for "Blue" would match "Blu" in "Lavender Blush", but searching for + * "lush" would only match the "L" in "Lavender". + */ + +import {nativeCompare, sortedIndex} from 'app/common/gutil'; +import {DomContents} from 'grainjs'; + +export interface ACItem { + // This should be a trimmed lowercase version of the item's text. It may be an accessor. + // Note that items with empty cleanText are never suggested. + cleanText: string; +} + +// Regexp used to split text into words; includes nearly all punctuation. This means that +// "foo-bar" may be searched by "bar", but it's impossible to search for punctuation itself (e.g. +// "a-b" and "a+b" are not distinguished). (It's easy to exclude unicode punctuation too if the +// need arises, see https://stackoverflow.com/a/25575009/328565). +const wordSepRegexp = /[\s!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]+/; + +/** + * An auto-complete index, which simply allows searching for a string. + */ +export interface ACIndex { + search(searchText: string): ACResults; +} + +// Splits text into an array of pieces, with odd-indexed pieces being the ones to highlight. +export type HighlightFunc = (text: string) => string[]; + +export const highlightNone: HighlightFunc = (text) => [text]; + +/** + * AutoComplete results include the suggested items, which one to highlight, and a function for + * highlighting the matched portion of each item. + */ +export interface ACResults { + // Matching items in order from best match to worst. + items: Item[]; + + // May be used to highlight matches using buildHighlightedDom(). + highlightFunc: HighlightFunc; + + // index of a good match (normally 0), or -1 if no great match + selectIndex: number; +} + +interface Word { + word: string; // The indexed word + index: number; // Index into _allItems for the item containing this word. + pos: number; // Position of the word within the item where it occurred. +} + +/** + * Implements a search index. It doesn't currently support updates; when any values change, the + * index needs to be rebuilt from scratch. + */ +export class ACIndexImpl implements ACIndex { + private _allItems: Item[]; + + // All words from _allItems, sorted. + private _words: Word[]; + + // Creates an index for the given list of items. + // The max number of items to suggest may be set using _maxResults (default is 50). + constructor(items: Item[], private _maxResults: number = 50) { + this._allItems = items.slice(0); + + // Collects [word, occurrence, position] tuples for all words in _allItems. + const allWords: Word[] = []; + for (let index = 0; index < this._allItems.length; index++) { + const item = this._allItems[index]; + const words = item.cleanText.split(wordSepRegexp).filter(w => w); + for (let pos = 0; pos < words.length; pos++) { + allWords.push({word: words[pos], index, pos}); + } + } + + allWords.sort((a, b) => nativeCompare(a.word, b.word)); + this._words = allWords; + } + + + // The main search function. SearchText will be cleaned (trimmed and lowercased) at the start. + // Empty search text returns the first N items in the search universe. + public search(searchText: string): ACResults { + const cleanedSearchText = searchText.trim().toLowerCase(); + const searchWords = cleanedSearchText.split(wordSepRegexp).filter(w => w); + + // Maps item index in _allItems to its score. + const myMatches = new Map(); + + if (searchWords.length > 0) { + // For each of searchWords, go through items with an overlap, and update their scores. + for (let k = 0; k < searchWords.length; k++) { + const searchWord = searchWords[k]; + for (const [itemIndex, score] of this._findOverlaps(searchWord, k)) { + myMatches.set(itemIndex, (myMatches.get(itemIndex) || 0) + score); + } + } + + // Give an extra point to items that start with the searchText. + for (const [itemIndex, score] of myMatches) { + if (this._allItems[itemIndex].cleanText.startsWith(cleanedSearchText)) { + myMatches.set(itemIndex, score + 1); + } + } + } + + // Array of pairs [itemIndex, score], sorted by score (desc) and itemIndex. + const sortedMatches = Array.from(myMatches) + .sort((a, b) => nativeCompare(b[1], a[1]) || nativeCompare(a[0], b[0])) + .slice(0, this._maxResults); + + const items: Item[] = sortedMatches.map(([index, score]) => this._allItems[index]); + + // Append enough non-matching items to reach maxResults. + for (let i = 0; i < this._allItems.length && items.length < this._maxResults; i++) { + if (this._allItems[i].cleanText && !myMatches.has(i)) { + items.push(this._allItems[i]); + } + } + + if (!cleanedSearchText) { + // In this case we are just returning the first few items. + return {items, highlightFunc: highlightNone, selectIndex: -1}; + } + + const highlightFunc = highlightMatches.bind(null, searchWords); + + // The best match is the first item. If it actually starts with the search text, AND has a + // strictly better score than other items, highlight it as a default selection. Otherwise, no + // item will be auto-selected. + let selectIndex = -1; + if (items.length > 0 && items[0].cleanText.startsWith(cleanedSearchText) && + (sortedMatches.length <= 1 || sortedMatches[1][1] < sortedMatches[0][1])) { + selectIndex = 0; + } + return {items, highlightFunc, selectIndex}; + } + + /** + * Given one of the search words, looks it up in the indexed list of words and searches up and + * down the list for all words that share a prefix with it. Each such word contributes something + * to the score of the index entry it is a part of. + * + * Returns a Map from the index entry (index into _allItems) to the score which this searchWord + * contributes to it. + * + * The searchWordPos argument is the position of searchWord in the overall search text (e.g. 0 + * if it's the first word). It is used for the position bonus, to give higher scores to entries + * whose words occur in the same order as in the search text. + */ + private _findOverlaps(searchWord: string, searchWordPos: number): Map { + const insertIndex = sortedIndex<{word: string}>(this._words, {word: searchWord}, + (a, b) => nativeCompare(a.word, b.word)); + + // Maps index of item to its score. + const scored = new Map(); + + // Search up and down the list, accepting smaller and smaller overlap. + for (const step of [1, -1]) { + let prefix = searchWord; + let index = insertIndex + (step > 0 ? 0 : -1); + while (prefix && index >= 0 && index < this._words.length) { + for ( ; index >= 0 && index < this._words.length; index += step) { + const wordEntry = this._words[index]; + // Once we reach a word that doesn't start with our prefix, break this loop, so we can + // reduce the length of the prefix and keep scanning. + if (!wordEntry.word.startsWith(prefix)) { break; } + + // The contribution of this word's to the score consists primarily of the length of + // overlap (i.e. length for the current prefix). + const baseScore = prefix.length; + + // To this we add 1 if the word matches exactly. + const fullWordBonus = (wordEntry.word === searchWord ? 1 : 0); + + // To prefer matches where words occur in the same order as searched (e.g. searching for + // "Foo B" should prefer "Foo Bar" over "Bar Foo"), we give a bonus based on the + // position of the word in the search text and the entry text. (If positions match as + // 0:0 and 1:1, the total position bonus is 2^0+2^(-2)=1.25; while the bonus from 0:1 + // and 1:0 would be 2^(-1) + 2^(-1)=1.0.) + const positionBonus = Math.pow(2, -(searchWordPos + wordEntry.pos)); + + const itemScore = baseScore + fullWordBonus + positionBonus; + // Each search word contributes only one score (e.g. a search for "Foo" will partially + // match both words in "forty five", but only the higher of the matches will count). + if (itemScore >= (scored.get(wordEntry.index) || 0)) { + scored.set(wordEntry.index, itemScore); + } + } + prefix = prefix.slice(0, -1); + } + } + return scored; + } +} + + +export type BuildHighlightFunc = (match: string) => DomContents; + +/** + * Converts text to DOM with matching bits of text rendered using highlight(match) function. + */ +export function buildHighlightedDom( + text: string, highlightFunc: HighlightFunc, highlight: BuildHighlightFunc +): DomContents { + if (!text) { return text; } + const parts = highlightFunc(text); + return parts.map((part, k) => k % 2 ? highlight(part) : part); +} + + +// Same as wordSepRegexp, but with capturing parentheses. +const wordSepRegexpParen = new RegExp(`(${wordSepRegexp.source})`); + +/** + * Splits text into pieces, with odd-numbered pieces the ones matching a prefix of some + * searchWord, i.e. the ones to highlight. + */ +function highlightMatches(searchWords: string[], text: string): string[] { + const textParts = text.split(wordSepRegexpParen); + const outputs = ['']; + for (let i = 0; i < textParts.length; i += 2) { + const word = textParts[i]; + const separator = textParts[i + 1] || ''; + const prefixLen = findLongestPrefixLen(word.toLowerCase(), searchWords); + if (prefixLen === 0) { + outputs[outputs.length - 1] += word + separator; + } else { + outputs.push(word.slice(0, prefixLen), word.slice(prefixLen) + separator); + } + } + return outputs; +} + +function findLongestPrefixLen(text: string, choices: string[]): number { + return choices.reduce((max, choice) => Math.max(max, findCommonPrefixLength(text, choice)), 0); +} + +function findCommonPrefixLength(text1: string, text2: string): number { + let i = 0; + while (i < text1.length && text1[i] === text2[i]) { ++i; } + return i; +} diff --git a/app/client/lib/CustomSectionElement.ts b/app/client/lib/CustomSectionElement.ts new file mode 100644 index 00000000..21deaabd --- /dev/null +++ b/app/client/lib/CustomSectionElement.ts @@ -0,0 +1,47 @@ +import { SafeBrowser, ViewProcess } from 'app/client/lib/SafeBrowser'; +import { PluginInstance } from 'app/common/PluginInstance'; + +export { ViewProcess } from 'app/client/lib/SafeBrowser'; + +/** + * A PluginCustomSection identifies one custom section in a plugin. + */ +export interface PluginCustomSection { + pluginId: string; + sectionId: string; +} + +export class CustomSectionElement { + + /** + * Get the list of all available custom sections in all plugins' contributions. + */ + public static getSections(plugins: PluginInstance[]): PluginCustomSection[] { + return plugins.reduce((acc, plugin) => { + const customSections = plugin.definition.manifest.contributions.customSections; + const pluginId = plugin.definition.id; + if (customSections) { + // collect identifiers + const sectionIds = customSections.map(section => ({sectionId: section.name, pluginId})); + // concat to the accumulator + return acc.concat(sectionIds); + } + return acc; + }, []); + } + + /** + * Find a section matching sectionName in the plugin instances' constributions and returns + * it. Returns `undefined` if not found. + */ + public static find(plugin: PluginInstance, sectionName: string): ViewProcess|undefined { + const customSections = plugin.definition.manifest.contributions.customSections; + if (customSections) { + const section = customSections.find(({ name }) => name === sectionName); + if (section) { + const safeBrowser = plugin.safeBrowser as SafeBrowser; + return safeBrowser.createViewProcess(section.path); + } + } + } +} diff --git a/app/client/lib/Delay.ts b/app/client/lib/Delay.ts new file mode 100644 index 00000000..716bcae7 --- /dev/null +++ b/app/client/lib/Delay.ts @@ -0,0 +1,91 @@ +/** + * A little class to make it easier to work with setTimeout/clearTimeout when it may need to get + * cancelled or rescheduled. + */ + +import {Disposable} from 'app/client/lib/dispose'; + +export class Delay extends Disposable { + + /** + * Returns a function which will schedule a call to cb(), forwarding the arguments. + * This is a static method that may be used without a Delay object. + * E.g. wrapWithDelay(10, cb)(1,2,3) will call cb(1,2,3) in 10ms. + */ + public static wrapWithDelay(ms: number, cb: (this: void, ...args: any[]) => any, + optContext?: any): (...args: any[]) => void; + public static wrapWithDelay(ms: number, cb: (this: T, ...args: any[]) => any, + optContext: T): (...args: any[]) => void { + return function(this: any, ...args: any[]) { + const ctx = optContext || this; + setTimeout(() => cb.apply(ctx, args), ms); + }; + } + + /** + * Returns a wrapped callback whose execution is delayed until the next animation frame. The + * returned callback may be disposed to cancel the delayed execution. + */ + public static untilAnimationFrame(cb: (this: void, ...args: any[]) => void, + optContext?: any): DisposableCB; + public static untilAnimationFrame(cb: (this: T, ...args: any[]) => void, + optContext: T): DisposableCB { + let reqId: number|null = null; + const f = function(...args: any[]) { + if (reqId === null) { + reqId = window.requestAnimationFrame(() => { + reqId = null; + cb.apply(optContext, args); + }); + } + }; + f.dispose = function() { + if (reqId !== null) { + window.cancelAnimationFrame(reqId); + } + }; + return f; + } + + private _timeoutId: ReturnType | null = null; + + public create() { + this.autoDisposeCallback(this.cancel); + } + + /** + * If there is a scheduled callback, clear it. + */ + public cancel() { + if (this._timeoutId !== null) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + } + + /** + * Returns whether there is a scheduled callback. + */ + public isPending() { + return this._timeoutId !== null; + } + + /** + * Schedule a new callback, to be called in ms milliseconds, optionally bound to the passed-in + * arguments. If another callback was scheduled, it is cleared first. + */ + + public schedule(ms: number, cb: (this: void, ...args: any[]) => any, optContext?: any, ...optArgs: any[]): void; + public schedule(ms: number, cb: (this: T, ...args: any[]) => any, optContext: T, ...optArgs: any[]): void { + this.cancel(); + this._timeoutId = setTimeout(() => { + this._timeoutId = null; + cb.apply(optContext, optArgs); + }, ms); + } +} + +export interface DisposableCB { + (...args: any[]): void; + dispose(): void; +} diff --git a/app/client/lib/DocPluginManager.ts b/app/client/lib/DocPluginManager.ts new file mode 100644 index 00000000..063dce67 --- /dev/null +++ b/app/client/lib/DocPluginManager.ts @@ -0,0 +1,66 @@ + +import {ClientScope} from 'app/client/components/ClientScope'; +import {SafeBrowser} from 'app/client/lib/SafeBrowser'; +import {ActiveDocAPI} from 'app/common/ActiveDocAPI'; +import {LocalPlugin} from 'app/common/plugin'; +import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance'; +import {Rpc} from 'grain-rpc'; + +/** + * DocPluginManager's Client side implementation. + */ +export class DocPluginManager { + + public pluginsList: PluginInstance[]; + + constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI, + private _clientScope: ClientScope) { + this.pluginsList = []; + for (const plugin of localPlugins) { + try { + const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`)); + const components = plugin.manifest.components || {}; + const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance, + this._clientScope, this._untrustedContentOrigin, components.safeBrowser); + if (components.safeBrowser) { + pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser); + } + + // Forward calls to the server, if no matching forwarder. + pluginInstance.rpc.registerForwarder('*', { + forwardCall: (call) => this._docComm.forwardPluginRpc(plugin.id, call), + forwardMessage: (msg) => this._docComm.forwardPluginRpc(plugin.id, msg), + }); + this.pluginsList.push(pluginInstance); + } catch (err) { + console.error( // tslint:disable-line:no-console + `DocPluginManager: failed to instantiate ${plugin.id}: ${err.message}`); + } + } + } + + /** + * `receiveAction` handles an action received from the server by forwarding it to all safe browser component. + */ + public receiveAction(action: any[]) { + for (const plugin of this.pluginsList) { + const safeBrowser = plugin.safeBrowser as SafeBrowser; + if (safeBrowser) { + safeBrowser.receiveAction(action); + } + } + } + + /** + * Make an Rpc object to call server methods from a url-flavored custom view. + */ + public makeAnonForwarder() { + const rpc = new Rpc({}); + rpc.queueOutgoingUntilReadyMessage(); + rpc.registerForwarder('*', { + forwardCall: (call) => this._docComm.forwardPluginRpc("builtIn/core", call), + forwardMessage: (msg) => this._docComm.forwardPluginRpc("builtIn/core", msg), + }); + return rpc; + } +} diff --git a/app/client/lib/ImportSourceElement.ts b/app/client/lib/ImportSourceElement.ts new file mode 100644 index 00000000..ddb6e138 --- /dev/null +++ b/app/client/lib/ImportSourceElement.ts @@ -0,0 +1,35 @@ +import {PluginInstance} from 'app/common/PluginInstance'; +import {InternalImportSourceAPI} from 'app/plugin/InternalImportSourceAPI'; +import {ImportSource} from 'app/plugin/PluginManifest'; +import {checkers} from 'app/plugin/TypeCheckers'; + +/** + * Encapsulate together an import source contribution with its plugin instance and a callable stub + * for the ImportSourceAPI. Exposes as well a `fromArray` static method to get all the import + * sources from an array of plugins instances. + */ +export class ImportSourceElement { + + /** + * Get all import sources from an array of plugin instances. + */ + public static fromArray(pluginInstances: PluginInstance[]): ImportSourceElement[] { + const importSources: ImportSourceElement[] = []; + for (const plugin of pluginInstances) { + const definitions = plugin.definition.manifest.contributions.importSources; + if (definitions) { + for (const importSource of definitions) { + importSources.push(new ImportSourceElement(plugin, importSource)); + } + } + } + return importSources; + } + + public importSourceStub: InternalImportSourceAPI; + + private constructor(public plugin: PluginInstance, public importSource: ImportSource) { + this.importSourceStub = plugin.getStub(importSource.importSource, + checkers.InternalImportSourceAPI); + } +} diff --git a/app/client/lib/Mousetrap.js b/app/client/lib/Mousetrap.js new file mode 100644 index 00000000..9d79a522 --- /dev/null +++ b/app/client/lib/Mousetrap.js @@ -0,0 +1,67 @@ +/** + * This file adds some includes tweaks to the behavior of Mousetrap.js, the keyboard bindings + * library. It exports the mousetrap library itself, so you may use it in mousetrap's place. + */ + + +/* global document */ + +if (typeof window === 'undefined') { + // We can't require('mousetrap') in a browserless environment (specifically for unittests) + // because it uses global variables right on require, which are not available with jsdom. + // So to use mousetrap in unittests, we need to stub it out. + module.exports = { + bind: function() {}, + unbind: function() {}, + }; +} else { + + var Mousetrap = require('mousetrap'); + var ko = require('knockout'); + + // Minus is different on Gecko: + // see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode + // and https://github.com/ccampbell/mousetrap/pull/215 + Mousetrap.addKeycodes({173: '-'}); + + var MousetrapProtype = Mousetrap.prototype; + var origStopCallback = MousetrapProtype.stopCallback; + + /** + * Enhances Mousetrap's stopCallback filter. Normally, mousetrap ignores key events in input + * fields and textareas. This replacement allows individual CommandGroups to be activated in such + * elements. See also 'attach' method of commands.CommandGroup. + */ + MousetrapProtype.stopCallback = function(e, element, combo, sequence) { + if (mousetrapBindingsPaused) { + return true; + } + var cmdGroup = ko.utils.domData.get(element, 'mousetrapCommandGroup'); + if (cmdGroup) { + return !cmdGroup.knownKeys.hasOwnProperty(combo); + } + try { + return origStopCallback.call(this, e, element, combo, sequence); + } catch (err) { + if (!document.body.contains(element)) { + // Mousetrap throws a pointless error in this case, which we ignore. It happens when + // element gets removed by a non-mousetrap keyboard handler. + return; + } + throw err; + } + }; + + + var mousetrapBindingsPaused = false; + + /** + * Globally pause or unpause mousetrap bindings. This is useful e.g. while a context menu is being + * shown, which has its own keyboard handling. + */ + Mousetrap.setPaused = function(yesNo) { + mousetrapBindingsPaused = yesNo; + }; + + module.exports = Mousetrap; +} diff --git a/app/client/lib/ObservableMap.js b/app/client/lib/ObservableMap.js new file mode 100644 index 00000000..0a2da8f3 --- /dev/null +++ b/app/client/lib/ObservableMap.js @@ -0,0 +1,155 @@ +var ko = require('knockout'); + +var dispose = require('./dispose'); + +/** + * ObservableMap provides a structure to keep track of values that need to recalculate in + * response to a key change or a mapping function change. + * + * @example + * let factor = ko.observable(2); + * let myFunc = ko.computed(() => { + * let f = factor(); + * return (keyId) => key * f; + * }); + * + * let myMap = ObservableMap.create(myFunc); + * let inObs1 = ko.observable(2); + * let inObs2 = ko.observable(3); + * + * let outObs1 = myMap.add(inObs1); + * let outObs2 = myMap.add(inObs2); + * outObs1(); // 4 + * outObs2(); // 6 + * + * inObs1(5); + * outObs1(); // 10 + * + * factor(3); + * outObs1(); // 15 + * outObs2(); // 9 + * + * + * @param {Function} mapFunc - Computed that returns a mapping function that takes in a key and + * returns a value. Whenever `mapFunc` is updated, all the current values in the map will be + * recalculated using the new function. + */ +function ObservableMap(mapFunc) { + this.store = new Map(); + this.mapFunc = mapFunc; + + // Recalculate all values on changes to mapFunc + let mapFuncSub = mapFunc.subscribe(() => { + this.updateAll(); + }); + + // Disposes all stored observable and clears the map. + this.autoDisposeCallback(() => { + // Unsbuscribe from mapping function + mapFuncSub.dispose(); + // Clear the store + this.store.forEach((val, key) => val.forEach(obj => obj.dispose())); + this.store.clear(); + }); +} +dispose.makeDisposable(ObservableMap); + +/** + * Takes an observable for the key value and returns an observable for the output. + * Subscribes to the given observable so that whenever it changes the output observable is + * updated to the value returned by `mapFunc` when provided the new key as input. + * If user disposes of the returned observable, it will be removed from the map. + * + * @param {ko.observable} obsKey + * @return {ko.observble} Observable value equal to `mapFunc(obsKey())` that will be updated on + * updates to `obsKey` and `mapFunc`. + */ +ObservableMap.prototype.add = function (obsKey) { + let currKey = obsKey(); + let ret = ko.observable(this.mapFunc()(currKey)); + + // Add to map + this._addKeyValue(currKey, ret); + + // Subscribe to changes to key + let subs = obsKey.subscribe(newKey => { + ret(this.mapFunc()(newKey)); + + if (currKey !== newKey) { + // If the key changed, add it to the new bucket and delete from the old one + this._addKeyValue(newKey, ret); + this._delete(currKey, ret); + // And update the key + currKey = newKey; + } + }); + ret.dispose = () => { + // On dispose, delete from map unless the whole map is being disposed + if (!this.isDisposed()) { + this._delete(currKey, ret); + } + subs.dispose(); + }; + + return ret; +}; + +/** + * Returns the Set of observable values for the given key. + */ +ObservableMap.prototype.get = function (key) { + return this.store.get(key); +}; + +ObservableMap.prototype._addKeyValue = function (key, value) { + if (!this.store.has(key)) { + this.store.set(key, new Set([value])); + } else { + this.store.get(key).add(value); + } +}; + +/** + * Triggers an update for all keys. + */ +ObservableMap.prototype.updateAll = function () { + this.store.forEach((val, key) => this.updateKey(key)); +}; + +/** + * Triggers an update for all observables for given keys in the map. + * @param {Array} keys + */ +ObservableMap.prototype.updateKeys = function (keys) { + keys.forEach(key => this.updateKey(key)); +}; + +/** + * Triggers an update for all observables for the given key in the map. + * @param {Any} key + */ +ObservableMap.prototype.updateKey = function (key) { + if (this.store.has(key) && this.store.get(key).size > 0) { + this.store.get(key).forEach(obj => { + obj(this.mapFunc()(key)); + }); + } +}; + +/** + * Given a key and an observable, deletes the observable from that key's bucket. + * + * @param {Any} key - Current value of the key. + * @param {Any} obsValue - An observable previously returned by `add`. + */ +ObservableMap.prototype._delete = function (key, obsValue) { + if (this.store.has(key) && this.store.get(key).size > 0) { + this.store.get(key).delete(obsValue); + // Clean up empty buckets + if (this.store.get(key).size === 0) { + this.store.delete(key); + } + } +}; + +module.exports = ObservableMap; diff --git a/app/client/lib/ObservableSet.js b/app/client/lib/ObservableSet.js new file mode 100644 index 00000000..3580013b --- /dev/null +++ b/app/client/lib/ObservableSet.js @@ -0,0 +1,74 @@ +var _ = require('underscore'); +var ko = require('knockout'); +var dispose = require('./dispose'); + +/** + * An ObservableSet keeps track of a set of values whose membership is controlled by a boolean + * observable. + * @property {ko.observable} count: Count of items that are currently included. + */ +function ObservableSet() { + this._items = {}; + this.count = ko.observable(0); +} +dispose.makeDisposable(ObservableSet); + +/** + * Adds an item to keep track of. The value is added to the set whenever isIncluded observable is + * true. To stop keeping track of this item, call dispose() on the returned object. + * + * @param {ko.observable} isIncluded: observable for whether to include the value. + * @param {Object} value: Arbitrary value. May be omitted if you only care about the count. + * @return {Object} Object with dispose() method, which can be called to unsubscribe from + * isIncluded, and remove the value from the set. + */ +ObservableSet.prototype.add = function(isIncluded, value) { + var uniqueKey = _.uniqueId(); + var sub = this.autoDispose(isIncluded.subscribe(function(include) { + if (include) { + this._add(uniqueKey, value); + } else { + this._remove(uniqueKey); + } + }, this)); + + if (isIncluded.peek()) { + this._add(uniqueKey, value); + } + + return { + dispose: function() { + this._remove(uniqueKey); + this.disposeDiscard(sub); + }.bind(this) + }; +}; + +/** + * Returns an array of all the values that are currently included in the set. + */ +ObservableSet.prototype.all = function() { + return _.values(this._items); +}; + +/** + * Internal helper to add a value to the set. + */ +ObservableSet.prototype._add = function(key, value) { + if (!this._items.hasOwnProperty(key)) { + this._items[key] = value; + this.count(this.count() + 1); + } +}; + +/** + * Internal helper to remove a value from the set. + */ +ObservableSet.prototype._remove = function(key) { + if (this._items.hasOwnProperty(key)) { + delete this._items[key]; + this.count(this.count() - 1); + } +}; + +module.exports = ObservableSet; diff --git a/app/client/lib/SafeBrowser.ts b/app/client/lib/SafeBrowser.ts new file mode 100644 index 00000000..e0c7a4fc --- /dev/null +++ b/app/client/lib/SafeBrowser.ts @@ -0,0 +1,331 @@ +/** + * The SafeBrowser component implementation is responsible for executing the safeBrowser component + * of a plugin. + * + * A plugin's safeBrowser component is made of one main entry point (the javascript files declares + * in the manifest), html files and any ressources included by the html files (css, scripts, images + * ...). The main script is the main entry point which uses the Grist API to render the views, + * communicate with them en dispose them. + * + * The main script is executed within a WebWorker, and the html files are rendered within webviews + * if run within electron, or iframe in case of the browser. + * + * Communication between the main process and the views are handle with rpc. + * + * If the plugins includes as well an unsafeNode component or a safePython component and if one of + * them registers a function using the Grist Api, this function can then be called from within the + * safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`. + * + * The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`. + * + * All the safeBrowser's component ressources, including the main script, the html files and any + * other ressources needed by the views, should be placed within one plugins' subfolder, and Grist + * should serve only this folder. However, this is not yet implemented and is left as a TODO, as of + * now the whole plugin's folder is served. + * + */ + // Todo: plugin ressources should not be made available on the server by default, but only after + // activation. + +// tslint:disable:max-classes-per-file + +import { ClientScope } from 'app/client/components/ClientScope'; +import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals'; +import * as dom from 'app/client/lib/dom'; +import * as Mousetrap from 'app/client/lib/Mousetrap'; +import { ActionRouter } from 'app/common/ActionRouter'; +import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance'; +import { tbind } from 'app/common/tbind'; +import { getOriginUrl } from 'app/common/urlUtils'; +import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI'; +import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions'; +import { checkers } from 'app/plugin/TypeCheckers'; +import { IpcMessageEvent, WebviewTag } from 'electron'; +import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc'; +import { Disposable } from './dispose'; +const G = getBrowserGlobals('document', 'window'); + +/** + * The SafeBrowser component implementation. Responsible for running the script, rendering the + * views, settings up communication channel. + */ + // todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which + // really have no business outside of this module. What could be done, is to have an internal class + // ProcessManager which will be created by SafeBrowser as a private field. It will manage the + // client processes and among other thing will expose both renderImpl and + // disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser. +export class SafeBrowser extends BaseComponent { + + /** + * Create a webview ClientProcess to render safe browser process in electron. + */ + public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess { + return new WorkerProcess(safeBrowser, rpc, src); + } + + /** + * Create either an iframe or a webview ClientProcess depending on wether running electron or not. + */ + public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess { + return G.window.isRunningUnderElectron ? + new WebviewProcess(safeBrowser, rpc, src) : + new IframeProcess(safeBrowser, rpc, src); + } + + // All view processes. This is not used anymore to dispose all processes on deactivation (this is + // now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch + // events to all processes (such as doc actions which will need soon). + private _viewProcesses: Map = new Map(); + private _pluginId: string; + private _pluginRpc: Rpc; + private _mainProcess: WorkerProcess|undefined; + private _viewCount: number = 0; + + constructor( + private _plugin: PluginInstance, + private _clientScope: ClientScope, + private _untrustedContentOrigin: string, + private _mainPath: string = "", + private _baseLogger: BaseLogger = console, + rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`), + ) { + super(_plugin.definition.manifest, rpcLogger); + this._pluginId = _plugin.definition.id; + this._pluginRpc = _plugin.rpc; + } + + /** + * Render the file at path in an iframe or webview and returns its ViewProcess. + */ + public createViewProcess(path: string): ViewProcess { + return this._createViewProcess(path)[0]; + } + /** + * `receiveAction` handles an action received from the server by forwarding it to the view processes. + */ + public receiveAction(action: any[]) { + for (const view of this._viewProcesses.values()) { + view.receiveAction(action); + } + } + + + /** + * Renders the file at path and returns its proc id. This is the SafeBrowser implementation for + * the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts. + */ + public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise { + const [proc, viewId] = this._createViewProcess(path); + const renderFunc = this._plugin.getRenderTarget(target, options); + renderFunc(proc.element); + if (this._mainProcess) { + // Disposing the web worker should dispose all view processes that created using the + // gristAPI. There is a flaw here: please read [1]. + this._mainProcess.autoDispose(proc); + } + return viewId; + // [1]: When a process, which is not owned by the mainProcess (ie: a process which was created + // using `public createViewProcess(...)'), creates a view process using the gristAPI, the + // rendered view will be owned by the main process. This is not correct and could cause views to + // suddently disappear from the screen. This is pretty nasty. But for following reason I think + // it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of + // SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the + // future (we could as well directly register contribution using files directly in the + // manifest), and (3) plugins are only developped by us, we only have to remember that using + // `render()` is only supported from within the main process (which cover all our use cases so + // far). + } + + /** + * Dispose the process using it's proc id. This is the SafeBrowser implementation for the + * GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts. + */ + public async disposeImpl(procId: number): Promise { + const proc = this._viewProcesses.get(procId); + if (proc) { + this._viewProcesses.delete(procId); + proc.dispose(); + } + } + + protected doForwardCall(c: IMsgRpcCall): Promise { + if (this._mainProcess) { + return this._mainProcess.rpc.forwardCall(c); + } + // should not happen. + throw new Error("Using SafeBrowser as an IForwarder requires a main script"); + } + + protected doForwardMessage(c: IMsgCustom): Promise { + if (this._mainProcess) { + return this._mainProcess.rpc.forwardMessage(c); + } + // should not happen. + throw new Error("Using SafeBrowser as an IForwarder requires a main script"); + } + + protected async activateImplementation(): Promise { + if (this._mainPath) { + const rpc = this._createRpc(this._mainPath); + const src = `plugins/${this._pluginId}/${this._mainPath}`; + // This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and + // forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward* + // methods). Note that those calls are what triggers component activation. + this._mainProcess = SafeBrowser.createWorker(this, rpc, src); + } + } + + protected async deactivateImplementation(): Promise { + if (this._mainProcess) { + this._mainProcess.dispose(); + } + } + + /** + * Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses` + * using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name + * `path`. Unregister both on disposal. + */ + private _createViewProcess(path: string): [ViewProcess, number] { + const rpc = this._createRpc(path); + const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}` + + `?host=${G.window.location.origin}`; + const viewId = this._viewCount++; + const process = SafeBrowser.createView(this, rpc, url); + this._viewProcesses.set(viewId, process); + this._pluginRpc.registerForwarder(path, rpc); + process.autoDisposeCallback(() => { + this._pluginRpc.unregisterForwarder(path); + this._viewProcesses.delete(viewId); + }); + return [process, viewId]; + } + + /** + * Create an rpc instance and set it up for communicating with a ClientProcess: + * - won't send any message before receiving a ready message + * - has the '*' forwarder set to the plugin's instance rpc + * - has registered an implementation of the gristAPI. + * Returns the rpc instance. + */ + private _createRpc(path: string): Rpc { + const rpc = new Rpc({logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) }); + rpc.queueOutgoingUntilReadyMessage(); + warnIfNotReady(rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin"); + rpc.registerForwarder('*', this._pluginRpc); + // TODO: we should be able to stop serving plugins, it looks like there are some resources + // required that should be disposed on component deactivation. + this._clientScope.servePlugin(this._pluginId, rpc); + return rpc; + } + + +} + +/** + * Base class for any client process. `onDispose` allows to register a callback that will be + * triggered when dispose() is called. This is for internally use. + */ +export class ClientProcess extends Disposable { + public rpc: Rpc; + + private _safeBrowser: SafeBrowser; + private _src: string; + private _actionRouter: ActionRouter; + + public create(...args: any[]): void; + public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) { + this.rpc = rpc; + this._safeBrowser = safeBrowser; + this._src = src; + this._actionRouter = new ActionRouter(this.rpc); + const gristAPI: GristAPI = { + subscribe: tbind(this._actionRouter.subscribeTable, this._actionRouter), + unsubscribe: tbind(this._actionRouter.unsubscribeTable, this._actionRouter), + render: tbind(this._safeBrowser.renderImpl, this._safeBrowser), + dispose: tbind(this._safeBrowser.disposeImpl, this._safeBrowser), + }; + rpc.registerImpl(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI); + this.autoDisposeCallback(() => { + this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE); + }); + } + + public receiveAction(action: any[]) { + this._actionRouter.process(action) + // tslint:disable:no-console + .catch((err: any) => console.warn("ClientProcess[%s] receiveAction: failed with %s", this._src, err)); + } + +} + +/** + * The web worker client process, used to execute safe browser main script. + */ +class WorkerProcess extends ClientProcess { + public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) { + super.create(safeBrowser, rpc, src); + // Serve web worker script from same host as current page + const worker = new Worker(getOriginUrl(`/${src}`)); + worker.addEventListener("message", (e: MessageEvent) => this.rpc.receiveMessage(e.data)); + this.rpc.setSendMessage(worker.postMessage.bind(worker)); + this.autoDisposeCallback(() => worker.terminate()); + } +} + +export class ViewProcess extends ClientProcess { + public element: HTMLElement; +} + +/** + * The Iframe ClientProcess used to render safe browser content in the browser. + */ +class IframeProcess extends ViewProcess { + public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) { + super.create(safeBrowser, rpc, src); + const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`, + { src })); + const listener = (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + this.rpc.receiveMessage(event.data); + } + }; + G.window.addEventListener('message', listener); + this.autoDisposeCallback(() => { + G.window.removeEventListener('message', listener); + }); + this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*')); + } + +} + +/** + * The webview ClientProcess to render safe browser process in electron. + */ +class WebviewProcess extends ViewProcess { + public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) { + super.create(safeBrowser, rpc, src); + const webview: WebviewTag = this.element = this.autoDispose(dom('webview.safe_browser_process.clipboard_focus', { + src, + allowpopups: '', + // Requests with this partition get an extra header (see main.js) to get access to plugin content. + partition: 'plugins', + })); + // Temporaily disable "mousetrap" keyboard stealing for the duration of this webview. + // This is acceptable since webviews are currently full-screen modals. + // TODO: find a way for keyboard events to play nice when webviews are non-modal. + Mousetrap.setPaused(true); + this.autoDisposeCallback(() => Mousetrap.setPaused(false)); + webview.addEventListener('ipc-message', (event: IpcMessageEvent) => { + // The event object passed to the listener is missing proper documentation. In the examples + // listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the + // listener after the event object, but this is not happening here. Only we know it is a + // DOMEvent with some extra porperties including a `channel` property of type `string` and an + // `args` property of type `any[]`. + if (event.channel === 'grist') { + rpc.receiveMessage(event.args[0]); + } + }); + this.rpc.setSendMessage(msg => webview.send('grist', msg)); + } +} diff --git a/app/client/lib/SafeBrowserProcess.css b/app/client/lib/SafeBrowserProcess.css new file mode 100644 index 00000000..0aee3234 --- /dev/null +++ b/app/client/lib/SafeBrowserProcess.css @@ -0,0 +1,12 @@ +.plugin_instance_fullscreen { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + z-index:9999; +} + +.safe_browser_process{ + border: none; +} diff --git a/app/client/lib/autocomplete.ts b/app/client/lib/autocomplete.ts new file mode 100644 index 00000000..fd493d48 --- /dev/null +++ b/app/client/lib/autocomplete.ts @@ -0,0 +1,239 @@ +/** + * Implements an autocomplete dropdown. + */ +import {createPopper, Instance as Popper, Modifier, Options as PopperOptions} from '@popperjs/core'; +import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex'; +import {reportError} from 'app/client/models/errors'; +import {Disposable, dom, EventCB, IDisposable} from 'grainjs'; +import {obsArray, onKeyElem, styled} from 'grainjs'; +import merge = require('lodash/merge'); +import maxSize from 'popper-max-size-modifier'; +import {cssMenu} from 'popweasel'; + + +export interface IAutocompleteOptions { + // If provided, applies the css class to the menu container. Could be multiple, space-separated. + menuCssClass?: string; + + // A single class name to add for the selected item, or 'selected' by default. + selectedCssClass?: string; + + // Popper options for positioning the popup. + popperOptions?: Partial; + + // Given a search term, return the list of Items to render. + search(searchText: string): Promise>; + + // Function to render a single item. + renderItem(item: Item, highlightFunc: HighlightFunc): HTMLElement; + + // Get text for the text input for a selected item, i.e. the text to present to the user. + getItemText(item: Item): string; + + // A callback triggered when user clicks one of the choices. + onClick?(): void; +} + +/** + * An instance of an open Autocomplete dropdown. + */ +export class Autocomplete extends Disposable { + // The UL element containing the actual menu items. + protected _menuContent: HTMLElement; + + // Index into _items as well as into _menuContent, -1 if nothing selected. + protected _selectedIndex: number = -1; + + // Currently selected element. + protected _selected: HTMLElement|null = null; + + private _popper: Popper; + private _mouseOver: {reset(): void}; + private _lastAsTyped: string; + private _items = this.autoDispose(obsArray([])); + private _highlightFunc: HighlightFunc; + + constructor( + private _triggerElem: HTMLInputElement | HTMLTextAreaElement, + private readonly options: IAutocompleteOptions, + ) { + super(); + + const content = cssMenuWrap( + this._menuContent = cssMenu({class: options.menuCssClass || ''}, + dom.forEach(this._items, (item) => options.renderItem(item, this._highlightFunc)), + dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'), + dom.on('mouseleave', (ev) => this._setSelected(-1, true)), + dom.on('click', (ev) => { + this._setSelected(this._findTargetItem(ev.target), true); + if (options.onClick) { options.onClick(); } + }) + ), + // Prevent trigger element from being blurred on click. + dom.on('mousedown', (ev) => ev.preventDefault()), + ); + + this._mouseOver = attachMouseOverOnMove(this._menuContent, + (ev) => this._setSelected(this._findTargetItem(ev.target), true)); + + // Add key handlers to the trigger element as well as the menu if it is an input. + this.autoDispose(onKeyElem(_triggerElem, 'keydown', { + ArrowDown: () => this._setSelected(this._getNext(1), true), + ArrowUp: () => this._setSelected(this._getNext(-1), true), + })); + + // Keeps track of the last value as typed by the user. + this.search(); + this.autoDispose(dom.onElem(_triggerElem, 'input', () => this.search())); + + // Attach the content to the page. + document.body.appendChild(content); + this.onDispose(() => { dom.domDispose(content); content.remove(); }); + + // Prepare and create the Popper instance, which places the content according to the options. + const popperOptions = merge({}, defaultPopperOptions, options.popperOptions); + this._popper = createPopper(_triggerElem, content, popperOptions); + this.onDispose(() => this._popper.destroy()); + } + + public getSelectedItem(): Item|undefined { + return this._items.get()[this._selectedIndex]; + } + + public search(findMatch?: (items: Item[]) => number) { + this._updateChoices(this._triggerElem.value, findMatch).catch(reportError); + } + + // When the selected element changes, update the classes of the formerly and newly-selected + // elements and optionally update the text input. + private _setSelected(index: number, updateValue: boolean) { + const elem = (this._menuContent.children[index] as HTMLElement) || null; + const prev = this._selected; + if (elem !== prev) { + const clsName = this.options.selectedCssClass || 'selected'; + if (prev) { prev.classList.remove(clsName); } + if (elem) { + elem.classList.add(clsName); + elem.scrollIntoView({block: 'nearest'}); + } + } + this._selected = elem; + this._selectedIndex = elem ? index : -1; + + if (updateValue) { + // Update trigger's value with the selected choice, or else with the last typed value. + if (elem) { + this._triggerElem.value = this.options.getItemText(this.getSelectedItem()!); + } else { + this._triggerElem.value = this._lastAsTyped; + } + } + } + + private _findTargetItem(target: EventTarget|null): number { + // Find immediate child of this._menuContent which is an ancestor of ev.target. + const elem = findAncestorChild(this._menuContent, target as Element|null); + return Array.prototype.indexOf.call(this._menuContent.children, elem); + } + + private _getNext(step: 1 | -1): number { + // Pretend there is an extra element at the end to mean "nothing selected". + const xsize = this._items.get().length + 1; + const next = (this._selectedIndex + step + xsize) % xsize; + return (next === xsize - 1) ? -1 : next; + } + + private async _updateChoices(inputVal: string, findMatch?: (items: Item[]) => number): Promise { + this._lastAsTyped = inputVal; + // TODO We should perhaps debounce the search() call in some clever way, to avoid unnecessary + // searches while typing. Today, search() is synchronous in practice, so it doesn't matter. + const acResults = await this.options.search(inputVal); + this._highlightFunc = acResults.highlightFunc; + this._items.set(acResults.items); + + // Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling + // before the positions are updated, it causes the entire page to scroll horizontally. + this._popper.forceUpdate(); + + this._mouseOver.reset(); + + let index: number; + if (findMatch) { + index = findMatch(this._items.get()); + } else { + index = inputVal ? acResults.selectIndex : -1; + } + this._setSelected(index, false); + } +} + + +// The maxSize modifiers follow recommendations at https://www.npmjs.com/package/popper-max-size-modifier +const calcMaxSize = { + ...maxSize, + options: {padding: 4}, +}; + +const applyMaxSize: Modifier = { + name: 'applyMaxSize', + enabled: true, + phase: 'beforeWrite', + requires: ['maxSize'], + fn({state}: any) { + // The `maxSize` modifier provides this data + const {height} = state.modifiersData.maxSize; + Object.assign(state.styles.popper, { + maxHeight: `${Math.max(160, height)}px` + }); + } +}; + +export const defaultPopperOptions: Partial = { + placement: 'bottom-start', + modifiers: [ + calcMaxSize, + applyMaxSize, + {name: "computeStyles", options: {gpuAcceleration: false}}, + ], +}; + + +/** + * Helper function which returns the direct child of ancestor which is an ancestor of elem, or + * null if elem is not a descendant of ancestor. + */ +function findAncestorChild(ancestor: Element, elem: Element|null): Element|null { + while (elem && elem.parentElement !== ancestor) { + elem = elem.parentElement; + } + return elem; +} + +/** + * A version of dom.onElem('mouseover') that doesn't start firing until there is first a 'mousemove'. + * This way if an element is created under the mouse cursor (triggered by the keyboard, for + * instance) it's not immediately highlighted, but only when a user moves the mouse. + * Returns an object with a reset() method, which restarts the wait for mousemove. + */ +function attachMouseOverOnMove(elem: T, callback: EventCB) { + let lis: IDisposable|undefined; + function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB) { + if (lis) { lis.dispose(); } + lis = dom.onElem(elem, eventType, cb); + } + function reset() { + setListener('mousemove', (ev, _elem) => { + setListener('mouseover', callback); + callback(ev, _elem); + }); + } + reset(); + return {reset}; +} + +const cssMenuWrap = styled('div', ` + position: absolute; + display: flex; + flex-direction: column; + outline: none; +`); diff --git a/app/client/lib/browserGlobals.js b/app/client/lib/browserGlobals.js new file mode 100644 index 00000000..2a407c58 --- /dev/null +++ b/app/client/lib/browserGlobals.js @@ -0,0 +1,54 @@ +/** + * Module that allows client-side code to use browser globals (such as `document` or `Node`) in a + * way that allows those globals to be replaced by mocks in browser-less tests. + * + * E.g. test/client/clientUtil.js can replace globals with those provided by jsdom. + */ + + +var allGlobals = []; + +/* global window */ +var globalVars = (typeof window !== 'undefined' ? window : {}); + +/** + * Usage: to get access to global variables `foo` and `bar`, call: + * var G = require('browserGlobals').get('foo', 'bar'); + * and use G.foo and G.bar. + * + * This modules stores a reference to G, so that setGlobals() call can replace the values to which + * G.foo and G.bar refer. + */ +function get(varArgNames) { + var obj = { + neededNames: Array.prototype.slice.call(arguments), + globals: {} + }; + updateGlobals(obj); + allGlobals.push(obj); + return obj.globals; +} +exports.get = get; + +/** + * Internal helper which updates properties of all globals objects created with get(). + */ +function updateGlobals(obj) { + obj.neededNames.forEach(function(key) { + obj.globals[key] = globalVars[key]; + }); +} + +/** + * Replace globals with those from the given object. The previous mapping of global values is + * returned, so that it can be restored later. + */ +function setGlobals(globals) { + var oldVars = globalVars; + globalVars = globals; + allGlobals.forEach(function(obj) { + updateGlobals(obj); + }); + return oldVars; +} +exports.setGlobals = setGlobals; diff --git a/app/client/lib/browserInfo.ts b/app/client/lib/browserInfo.ts new file mode 100644 index 00000000..8be428c9 --- /dev/null +++ b/app/client/lib/browserInfo.ts @@ -0,0 +1,13 @@ +import * as Bowser from "bowser"; // TypeScript + +let parser: Bowser.Parser.Parser|undefined; + +function getParser() { + return parser || (parser = Bowser.getParser(window.navigator.userAgent)); +} + +// Returns whether the browser we are in is a desktop browser. +export function isDesktop() { + const platformType = getParser().getPlatformType(); + return (!platformType || platformType === 'desktop'); +} diff --git a/app/client/lib/chartUtil.ts b/app/client/lib/chartUtil.ts new file mode 100644 index 00000000..f183bf60 --- /dev/null +++ b/app/client/lib/chartUtil.ts @@ -0,0 +1,20 @@ +import {typedCompare} from 'app/common/SortFunc'; +import {Datum} from 'plotly.js'; + +/** + * Sort all values in a list of series according to the values in the first one. + */ +export function sortByXValues(series: Array<{values: Datum[]}>): void { + // The order of points matters for graph types that connect points with lines: the lines are + // drawn in order in which the points appear in the data. For the chart types we support, it + // only makes sense to keep the points sorted. (The only downside is that Grist line charts can + // no longer produce arbitrary line drawings.) + if (!series[0]) { return; } + const xValues = series[0].values; + const indices = xValues.map((val, i) => i); + indices.sort((a, b) => typedCompare(xValues[a], xValues[b])); + for (const s of series) { + const values = s.values; + s.values = indices.map((i) => values[i]); + } +} diff --git a/app/client/lib/copyToClipboard.ts b/app/client/lib/copyToClipboard.ts new file mode 100644 index 00000000..df200856 --- /dev/null +++ b/app/client/lib/copyToClipboard.ts @@ -0,0 +1,38 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; + +const G = getBrowserGlobals('document', 'window'); + +/** + * Copy some text to the clipboard, by hook or by crook. + */ +export async function copyToClipboard(txt: string) { + // If present and we have permission to use it, the navigator.clipboard interface + // is convenient. This method works in non-headless tests, and regular chrome + // and firefox. + if (G.window.navigator && G.window.navigator.clipboard && G.window.navigator.clipboard.writeText) { + try { + await G.window.navigator.clipboard.writeText(txt); + return; + } catch (e) { + // no joy, try another way. + } + } + // Otherwise fall back on document.execCommand('copy'), which requires text in + // the dom to be selected. Implementation here based on: + // https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f + // This fallback takes effect at least in headless tests, and in Safari. + const stash = G.document.createElement('textarea'); + stash.value = txt; + stash.setAttribute('readonly', ''); + stash.style.position = 'absolute'; + stash.style.left = '-10000px'; + G.document.body.appendChild(stash); + const selection = G.document.getSelection().rangeCount > 0 && G.document.getSelection().getRangeAt(0); + stash.select(); + G.document.execCommand('copy'); + G.document.body.removeChild(stash); + if (selection) { + G.document.getSelection().removeAllRanges(); + G.document.getSelection().addRange(selection); + } +} diff --git a/app/client/lib/dispose.d.ts b/app/client/lib/dispose.d.ts new file mode 100644 index 00000000..2bc6a8c2 --- /dev/null +++ b/app/client/lib/dispose.d.ts @@ -0,0 +1,16 @@ +// TODO: add remaining Disposable methode +export abstract class Disposable { + public static create any>( + this: T, ...args: ConstructorParameters): InstanceType; + + constructor(...args: any[]); + public dispose(): void; + public isDisposed(): boolean; + public autoDispose(obj: T): T; + public autoDisposeCallback(callback: () => void): void; + public disposeRelease(obj: T): T; + public disposeDiscard(obj: any): void; + public makeDisposable(obj: any): void; +} + +export function emptyNode(node: Node): void; diff --git a/app/client/lib/dispose.js b/app/client/lib/dispose.js new file mode 100644 index 00000000..5aa1e437 --- /dev/null +++ b/app/client/lib/dispose.js @@ -0,0 +1,369 @@ +/** + * dispose.js provides tools to components that needs to dispose of resources, such as + * destroy DOM, and unsubscribe from events. The motivation with examples is presented here: + * + * https://phab.getgrist.com/w/disposal/ + */ + + +var ko = require('knockout'); +var util = require('util'); +var _ = require("underscore"); + +// Use the browser globals in a way that allows replacing them with mocks in tests. +var G = require('./browserGlobals').get('DocumentFragment', 'Node'); + +/** + * Disposable is a base class for components that need cleanup (e.g. maintain DOM, listen to + * events, subscribe to anything). It provides a .dispose() method that should be called to + * destroy the component, and .autoDispose() method that the component should use to take + * responsibility for other pieces that require cleanup. + * + * To define a disposable prototype: + * function Foo() { ... } + * dispose.makeDisposable(Foo); + * + * To define a disposable ES6 class: + * class Foo extends dispose.Disposable { create() {...} } + * + * NB: Foo should not have its construction logic in a constructor but in a `create` method + * instead. If Foo defines a constructor (for taking advantage of type checking) the constructor + * should only call super `super(arg1, arg2 ...)`. Any way calling `new Foo(...args)` safely + * construct the component. + * + * In Foo's constructor or methods, take ownership of other objects: + * this.bar = this.autoDispose(Bar.create(...)); + * The argument will get disposed when `this` is disposed. If it's a DOM node, it will get removed + * using ko.removeNode(). If it has a `dispose` method, it will be called. + * + * For more customized disposal: + * this.baz = this.autoDisposeWith('destroy', new Baz()); + * this.elem = this.autoDisposeWith(ko.cleanNode, document.createElement(...)); + * When `this` is disposed, will call this.baz.destroy(), and ko.cleanNode(this.elem). + * + * To call another method on disposal (e.g. to add custom disposal logic): + * this.autoDisposeCallback(this.myUnsubscribeAllMethod); + * The method will be called with `this` as context, and no arguments. + * + * To create Foo: + * var foo = Foo.create(args...); + * `Foo.create` ensures that if the constructor throws an exception, any calls to .autoDispose() + * that happened before that are honored. + * + * To dispose of Foo: + * foo.dispose(); + * Owned objects will be disposed in reverse order from which `autoDispose` were called. Note that + * `foo` is no longer usable afterwards, and all its properties are wiped. + * If Foo has a `stopListening` method (e.g. inherits from Backbone.Events), `dispose` will call + * it automatically, as if it were added with `this.autoDisposeCallback(this.stopListening)`. + * + * To release an owned object: + * this.disposeRelease(this.bar); + * + * To dispose of an owned object early: + * this.disposeDiscard(this.bar); + * + * To determine if a reference refers to object that has already been disposed: + * foo.isDisposed() + */ +class Disposable { + /** + * A safe constructor which calls dispose() in case the creation throws an exception. + */ + constructor(...args) { + safelyConstruct(this.create, this, args); + } + + /** + * Static method to allow rewriting old classes into ES6 without modifying their + * instantiation to use `new Foo()` (i.e. you can continue to use `Foo.create()`). + */ + static create(...args) { + return new this(...args); + } +} + +Object.assign(Disposable.prototype, { + /** + * Take ownership of `obj`, and dispose it when `this.dispose` is called. + * @param {Object} obj: Object to take ownership of. It can be a DOM node or an object with a + * `dispose` method. + * @returns {Object} obj + */ + autoDispose: function(obj) { + return this.autoDisposeWith(defaultDisposer, obj); + }, + + /** + * As for autoDispose, but we receive a promise of an object. We wait for it to + * resolve and then take ownership of it. We return a promise that resolves to + * the object, or to null if the owner is disposed in the meantime. + */ + autoDisposePromise: function(objPromise) { + return objPromise.then(obj => { + if (this.isDisposed()) { + defaultDisposer(obj); + return null; + } + this.autoDispose(obj); + return obj; + }); + }, + + /** + * Take ownership of `obj`, and dispose it when `this.dispose` is called by calling the + * specified function. + * @param {Function|String} disposer: If a function, disposer(obj) will be called to dispose the + * object, with `this` as the context. If a string, then obj[disposer]() will be called. E.g. + * this.autoDisposeWith('destroy', a); // will call a.destroy() + * this.autoDisposeWith(ko.cleanNode, b); // will call ko.cleanNode(b) + * @param {Object} obj: Object to take ownership of, on which `disposer` will be called. + * @returns {Object} obj + */ + autoDisposeWith: function(disposer, obj) { + var list = this._disposalList || (this._disposalList = []); + list.push({ obj: obj, + disposer: typeof disposer === 'string' ? methodDisposer(disposer) : disposer }); + return obj; + }, + + /** + * Adds the given callback to be called when `this.dispose` is called. + * @param {Function} callback: Called on disposal with `this` as the context and no arguments. + * @returns nothing + */ + autoDisposeCallback: function(callback) { + this.autoDisposeWith(callFuncHelper, callback); + }, + + /** + * Remove `obj` from the list of owned objects; it will not be disposed on `this.dispose`. + * @param {Object} obj: Object to release. + * @returns {Object} obj + */ + disposeRelease: function(obj) { + removeObjectToDispose(this._disposalList, obj); + return obj; + }, + + /** + * Dispose of an owned object `obj` now, and remove it from the list of owned objects. + * @param {Object} obj: Object to release. + * @returns nothing + */ + disposeDiscard: function(obj) { + var entry = removeObjectToDispose(this._disposalList, obj); + if (entry) { + entry.disposer.call(this, obj); + } + }, + + /** + * Returns whether this object has already been disposed. + */ + isDisposed: function() { + return this._disposalList === WIPED_VALUE; + }, + + /** + * Clean up `this` by disposing of all owned objects, and calling `stopListening()` if defined. + */ + dispose: function() { + if (this.isDisposed()) { + return; + } + + var disposalList = this._disposalList; + this._disposalList = WIPED_VALUE; // This makes isDisposed() true. + if (disposalList) { + // Go backwards through the disposal list, and dispose of everything. + for (var i = disposalList.length - 1; i >= 0; i--) { + var entry = disposalList[i]; + disposeHelper(this, entry.disposer, entry.obj); + } + } + + // Call stopListening if it exists. This is a convenience when using Backbone.Events. It's + // equivalent to calling this.autoDisposeCallback(this.stopListening) in constructor. + if (typeof this.stopListening === 'function') { + // Wrap in disposeHelper so that errors get caught. + disposeHelper(this, callFuncHelper, this.stopListening); + } + + // Finish by wiping out the object, since nothing should use it after dispose(). + // See https://phab.getgrist.com/w/disposal/ for more motivation. + wipeOutObject(this); + } +}); +exports.Disposable = Disposable; + + +/** + * The recommended way to make an object disposable. It simply adds the methods of `Disposable` to + * its prototype, and also adds a `Class.create()` function, for a safer way to construct objects + * (see `safeCreate` for explanation). For instance, + * function Foo(args...) {...} + * dispose.makeDisposable(Foo); + * Now you can create Foo objects with: + * var foo = Foo.create(args...); + * And dispose of them with: + * foo.dispose(); + */ +function makeDisposable(Constructor) { + Object.assign(Constructor.prototype, Disposable.prototype); + Constructor.create = safeConstructor; +} +exports.makeDisposable = makeDisposable; + + +/** + * Helper to create and construct an object safely: `safeCreate(Foo, ...)` is similar to `new + * Foo(...)`. The difference is that in case of an exception in the constructor, the dispose() + * method will be called on the partially constructed object. + * If you call makeDisposable(Foo), then Foo.create(...) is equivalent and more convenient. + * @returns {Object} the newly constructed object. + */ +function safeCreate(Constructor, varArgs) { + return safeConstructor.apply(Constructor, Array.prototype.slice.call(arguments, 1)); +} +exports.safeCreate = safeCreate; + + +/** + * Helper used by makeDisposable() for the `create` property of a disposable class. E.g. when + * assigned to Foo.create, the call `Foo.create(args)` becomes similar to `new Foo(args)`, but + * calls dispose() in case the constructor throws an exception. + */ +var safeConstructor = function(varArgs) { + var Constructor = this; + var obj = Object.create(Constructor.prototype); + return safelyConstruct(Constructor, obj, arguments); +}; + +var safelyConstruct = function(Constructor, obj, args) { + try { + Constructor.apply(obj, args); + return obj; + } catch (e) { + // Be a bit more helpful and concise in reporting errors: print error as an object (that + // includes its stacktrace in FF and Chrome), and avoid printing it multiple times as it + // bubbles up through the stack of safeConstructor calls. + if (!e.printed) { + let name = obj.constructor.name || Constructor.name; + console.error("Error constructing %s:", name, e); + // assigning printed to a string throws: TypeError: Cannot create property 'printed' on [...] + if (_.isObject(e)) { + e.printed = true; + } + } + obj.dispose(); + throw e; + } +}; + +// It doesn't matter what the value is, but some values cause more helpful errors than others. +// E.g. if x = "disposed", then x.foo() throws "undefined is not a function", while when x = null, +// x.foo() throws "Cannot read property 'foo' of null", which seems more helpful. +var WIPED_VALUE = null; + + +/** + * Wipe out the given object by setting each property to a dummy value. This is helpful for + * objects that are disposed and should be ready to be garbage-collected. The goals are: + * - If anything still refers to the object and uses it, we'll get an early error, rather than + * silently keep going, potentially doing useless work (or worse) and wasting resources. + * - If anything still refers to the object but doesn't use it, the fields of the object can + * still be garbage-collected. + * - If there are circular references between the object and its properties, they get broken, + * making the job easier for the garbage collector. + */ +function wipeOutObject(obj) { + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + obj[k] = WIPED_VALUE; + } + } +} + +/** + * Internal helper used by disposeDiscard() and disposeRelease(). It finds, removes, and returns + * an entry from the given disposalList. + */ +function removeObjectToDispose(disposalList, obj) { + if (disposalList) { + for (var i = 0; i < disposalList.length; i++) { + if (disposalList[i].obj === obj) { + var entry = disposalList[i]; + disposalList.splice(i, 1); + return entry; + } + } + } + return null; +} + +/** + * Internal helper to allow adding cleanup callbacks to the disposalList. It acts as the + * "disposer" for callback, by simply calling them with the same context that it is called with. + */ +var callFuncHelper = function(callback) { + callback.call(this); +}; + +/** + * Internal helper to dispose objects that need a differently-named method to be called on them. + * It's used by `autoDisposeWith` when the disposer is a string method name. + */ +function methodDisposer(methodName) { + return function(obj) { + obj[methodName](); + }; +} + +/** + * Internal helper to call a disposer on an object. It swallows errors (but reports them) to make + * sure that when we dispose of an object, an error in disposing of one owned part doesn't stop + * the disposal of the other parts. + */ +function disposeHelper(owner, disposer, obj) { + try { + disposer.call(owner, obj); + } catch (e) { + console.error("While disposing %s, error disposing %s: %s", + describe(owner), describe(obj), e); + } +} + +/** + * Helper for reporting errors during disposal. Try to report the type of the object. + */ +function describe(obj) { + return (obj && obj.constructor && obj.constructor.name ? obj.constructor.name : + util.inspect(obj, {depth: 1})); +} + +/** + * Internal helper that implements the default disposal for an object. It just supports removing + * DOM nodes with ko.removeNode, and calling dispose() on any part that has a `dispose` method. + */ +function defaultDisposer(obj) { + if (obj instanceof G.Node) { + // This does both knockout- and jquery-related cleaning, and removes the node from the DOM. + ko.removeNode(obj); + } else if (typeof obj.dispose === 'function') { + obj.dispose(); + } else { + throw new Error("Object has no 'dispose' method"); + } +} + +/** + * Removes all children of the given node, and all knockout bindings. You can use it as + * this.autoDisposeWith(dispose.emptyNode, node); + */ +function emptyNode(node) { + ko.virtualElements.emptyNode(node); + ko.cleanNode(node); +} + +exports.emptyNode = emptyNode; diff --git a/app/client/lib/dom.js b/app/client/lib/dom.js new file mode 100644 index 00000000..96e2400d --- /dev/null +++ b/app/client/lib/dom.js @@ -0,0 +1,453 @@ +// Builds a DOM tree or document fragment, easily. +// +// Usage: +// dom('a#link.c1.c2', {href:url}, 'Hello ', dom('span', 'world')); +// creates Node Hello world. +// dom.frag(dom('span', 'Hello'), ['blah', dom('div', 'world')]) +// creates document fragment with Helloblah
world
. +// +// Arrays among child arguments get flattened. Objects are turned into attributes. +// +// If an argument is a function it will be called with elem as the argument, +// which may be a convenient way to modify elements, set styles, or attach events. + + + +var ko = require('knockout'); + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +var G = require('./browserGlobals').get('document', 'Node', '$', 'window'); + +/** + * dom('tag#id.class1.class2' | Node, other args) + * The first argument is typically a string consisting of a tag name, with optional #foo suffix + * to add the ID 'foo', and zero or more .bar suffixes to add a css class 'bar'. If the first + * argument is a Node, that node is used for subsequent changes without creating a new one. + * + * The rest of the arguments are optional and may be: + * + * Nodes - which become children of the created element; + * strings - which become text node children; + * objects - of the form {attr: val} to set additional attributes on the element; + * Arrays of Nodes - which are flattened out and become children of the created element; + * functions - which are called with elem as the argument, for a chance to modify the + * element as it's being created. When functions return values (other than undefined), + * these return values get applied to the containing element recursively. + */ +function dom(firstArg, ...args) { + let elem; + if (firstArg instanceof G.Node) { + elem = firstArg; + } else { + elem = createElemFromString(firstArg, createDOMElement); + } + + return handleChildren(elem, arguments, 1); +} + +/** + * dom.svg('tag#id.class1.class2', other args) behaves much like `dom`, but does not accept Node + * as a first argument--only a tag string. Because SVG elements are created in a different + * namespace, `dom.svg` should be used for creating SVG elements such as `polygon`. + */ +dom.svg = function (firstArg, ...args) { + let elem = createElemFromString(firstArg, createSVGElement); + return handleChildren(elem, arguments, 1); +}; + +/** + * Given a tag string of the form 'tag#id.class1.class2' and an element creator function, returns + * a new tag element with the id and classes properly set. + */ +function createElemFromString(tagString, elemCreator) { + // We do careful hand-written parsing rather than use a regexp for speed. Using a regexp is + // significantly more expensive. + let tag, id, classes; + let dotPos = tagString.indexOf("."); + let hashPos = tagString.indexOf('#'); + if (dotPos === -1) { + dotPos = tagString.length; + } else { + classes = tagString.substring(dotPos + 1).replace(/\./g, ' '); + } + if (hashPos === -1) { + tag = tagString.substring(0, dotPos); + } else if (hashPos > dotPos) { + throw new Error('ID must come before classes in dom("' + tagString + '")'); + } else { + tag = tagString.substring(0, hashPos); + id = tagString.substring(hashPos + 1, dotPos); + } + + let elem = elemCreator(tag); + if (id) { elem.setAttribute('id', id); } + if (classes) { elem.setAttribute('class', classes); } + + return elem; +} + +function createDOMElement(tagName) { + return G.document.createElement(tagName); +} + +function createSVGElement(tagName) { + return G.document.createElementNS('http://www.w3.org/2000/svg', tagName); +} + +// Append the rest of the arguments as children, flattening arrays +function handleChildren(elem, children, index) { + for (var i = index, len = children.length; i < len; i++) { + var child = children[i]; + if (Array.isArray(child)) { + child = handleChildren(elem, child, 0); + } else if (typeof child == 'function') { + child = child(elem); + if (typeof child !== 'undefined') { + handleChildren(elem, [child], 0); + } + } else if (child === null || child === void 0) { + // nothing + } else if (child instanceof G.Node) { + elem.appendChild(child); + } else if (typeof child === 'object') { + for (var key in child) { + elem.setAttribute(key, child[key]); + } + } else { + elem.appendChild(G.document.createTextNode(child)); + } + } + return elem; +} + +/** + * Creates a DocumentFragment consisting of all arguments, flattening any arguments that are + * arrays. If any arguments or array elements are strings, those are turned into text nodes. + * All argument types supported by the dom() function are supported by dom.frag() as well. + */ +dom.frag = function(varArgNodes) { + var elem = G.document.createDocumentFragment(); + return handleChildren(elem, arguments, 0); +}; + + +/** + * Forward all or some arguments to the dom() call. E.g. + * + * dom(a, b, c, dom.fwdArgs(arguments, 2)); + * + * is equivalent to: + * + * dom(a, b, c, arguments[2], arguments[3], arguments[4], ...) + * + * It is very convenient to use in other functions which want to accept arbitrary arguments for + * dom() and forward them. See koForm.js for many examples. + * + * @param {Array|Arguments} args: Array or Arguments object containing arguments to forward. + * @param {Number} startIndex: The index of the first element to forward. + */ +dom.fwdArgs = function(args, startIndex) { + return function(elem) { + handleChildren(elem, args, startIndex); + }; +}; + + +/** + * Wraps the given function to make it easy to use as an argument to dom(). The passed-in function + * must take a DOM Node as the first argument, and the returned wrapped function may be called + * without this argument when used as an argument to dom(), in which case the original function + * will be called with the element being constructed. + * + * For example, if we define: + * foo.method = dom.inlinable(function(elem, a, b) { ... }); + * then the call + * dom('div', foo.method(1, 2)) + * translates to + * dom('div', function(elem) { foo.method(elem, 1, 2); }) + * which causes foo.method(elem, 1, 2) to be called with elem set to the DIV being constructed. + * + * When the first argument is a DOM Node, calls to the wrapped function proceed as usual. In both + * cases, `this` context is passed along to the wrapped function as expected. + */ +dom.inlinable = dom.inlineable = function inlinable(func) { + return function(optElem) { + if (optElem instanceof G.Node) { + return func.apply(this, arguments); + } else { + return wrapInlinable(func, this, arguments); + } + }; +}; + +function wrapInlinable(func, context, args) { + // The switch is an optimization which speeds things up substantially. + switch (args.length) { + case 0: return function(elem) { return func.call(context, elem); }; + case 1: return function(elem) { return func.call(context, elem, args[0]); }; + case 2: return function(elem) { return func.call(context, elem, args[0], args[1]); }; + case 3: return function(elem) { return func.call(context, elem, args[0], args[1], args[2]); }; + } + return function(elem) { + Array.prototype.unshift.call(args, elem); + return func.apply(context, args); + }; +} + + +/** + * Shortcut for document.getElementById. + */ +dom.id = function(id) { + return G.document.getElementById(id); +}; + +/** + * Hides the given element. Can be passed into dom(), e.g. dom('div', dom.hide, ...). + */ +dom.hide = function(elem) { + elem.style.display = 'none'; +}; + +/** + * Shows the given element, assuming that it's not hidden by a class. + */ +dom.show = function(elem) { + elem.style.display = ''; +}; + +/** + * Toggles the given element, assuming that it's not hidden by a class. The second argument is + * optional, and if provided will make toggle() behave as either show() or hide(). + * @returns {Boolean} Whether the element is visible after toggle. + */ +dom.toggle = function(elem, optYesNo) { + if (optYesNo === undefined) + optYesNo = (elem.style.display === 'none'); + elem.style.display = optYesNo ? '' : 'none'; + return elem.style.display !== 'none'; +}; + + +/** + * Set the given className on the element while it is being dragged over. + * Can be used inlined as in `dom(..., dom.dragOverClass('foo'))`. + * @param {String} className: Class name to set while a drag-over is in progress. + */ +dom.dragOverClass = dom.inlinable(function(elem, className) { + // Note: This is hard to get correct on both FF and Chrome because of dragenter/dragleave events that + // occur for contained elements. See + // http://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element. + // Here we use a reference count, and filter out duplicate dragenter events on the same target. + let counter = 0; + let lastTarget = null; + + dom.on(elem, 'dragenter', ev => { + if (Array.from(ev.originalEvent.dataTransfer.types).includes('text/html')) { + // This would not be present when dragging in an actual file. We return undefined, to avoid + // suppressing normal behavior (which is suppressed below when we return false). + return; + } + if (ev.target !== lastTarget) { + lastTarget = ev.target; + ev.originalEvent.dataTransfer.dropEffect = 'copy'; + if (!counter) { elem.classList.add(className); } + counter++; + } + return false; + }); + + dom.on(elem, 'dragleave', () => { + lastTarget = null; + counter = Math.max(0, counter - 1); + if (!counter) { elem.classList.remove(className); } + }); + + dom.on(elem, 'drop', () => { + lastTarget = null; + counter = 0; + elem.classList.remove(className); + }); +}); + + +/** + * Change a Node's childNodes similarly to Array splice. This allows removing and adding nodes. + * It translates to calls to replaceChild, insertBefore, removeChild, and appendChild, as + * appropriate. + * @param {Number} index Index at which to start changing the array. + * @param {Number} howMany Number of old array elements to remove or replace. + * @param {Node} optNewChildren This is an optional parameter specifying a new node to insert, + * and may be repeated to insert multiple nodes. Null values are ignored. + * @returns {Array[Node]} array of removed nodes. + * TODO: this desperately needs a unittest. + */ +dom.splice = function(node, index, howMany, optNewChildren) { + var end = Math.min(index + howMany, node.childNodes.length); + for (var i = 3; i < arguments.length; i++) { + if (arguments[i] !== null) { + if (index < end) { + node.replaceChild(arguments[i], node.childNodes[index]); + index++; + } else if (index < node.childNodes.length) { + node.insertBefore(arguments[i], node.childNodes[index]); + } else { + node.appendChild(arguments[i]); + } + } + } + var ret = Array.prototype.slice.call(node.childNodes, index, end); + while (end > index) { + node.removeChild(node.childNodes[--end]); + } + return ret; +}; + +/** + * Returns the index of the given node among its parent's children (i.e. its siblings). + */ +dom.childIndex = function(node) { + return Array.prototype.indexOf.call(node.parentNode.childNodes, node); +}; + + +function makeFilterFunc(selectorOrFunc) { + if (typeof selectorOrFunc === 'string') { + return function(elem) { return elem.matches && elem.matches(selectorOrFunc); }; + } + return selectorOrFunc; +} + +/** + * Iterates backwards through the children of `parent`, returning the first one matching the given + * selector or filter function. Returns null if no matching node is found. + */ +dom.findLastChild = function(parent, selectorOrFunc) { + var filterFunc = makeFilterFunc(selectorOrFunc); + for (var c = parent.lastChild; c; c = c.previousSibling) { + if (filterFunc(c)) { + return c; + } + } + return null; +}; + + +/** + * Iterates up the DOM tree from `child` to `container`, returning the first Node matching the + * given selector or filter function. Returns null for no match. + * If `container` is given, the returned node will be non-null only if contained in it. + * If `container` is omitted, the search will go all the way up. + */ +dom.findAncestor = function(child, optContainer, selectorOrFunc) { + if (arguments.length === 2) { + selectorOrFunc = optContainer; + optContainer = null; + } + var filterFunc = makeFilterFunc(selectorOrFunc); + var match = null; + while (child) { + if (!match && filterFunc(child)) { + match = child; + if (!optContainer) { + return match; + } + } + if (child === optContainer) { + return match; + } + child = child.parentNode; + } + return null; +}; + + +/** + * Detaches a Node from its parent, and returns the Node passed in. + */ +dom.detachNode = function(node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + return node; +}; + + +/** + * Use JQuery to attach an event handler to the given element. For documentation, see + * http://api.jquery.com/on/. You may use this inline while building dom, as: + * dom(..., dom.on(events, args...)) + * E.g. + * dom('div', + * dom.on('click', function(ev) { + * console.log(ev); + * }) + * ); + */ +dom.on = dom.inlinable(function(elem, events, optSelector, optData, handler) { + G.$(elem).on(events, optSelector, optData, handler); +}); + +dom.once = dom.inlinable(function(elem, events, optSelector, optData, handler) { + G.$(elem).one(events, optSelector, optData, handler); +}); + +/** + * Helper to do some processing on a DOM element after the current call stack has cleared. E.g. + * dom('input', + * dom.defer(function(elem) { + * elem.focus(); + * }) + * ); + * will cause elem.focus() to be called for the INPUT element after a setTimeout of 0. + * + * This is often useful for dealing with focusing and selection. + */ +dom.defer = function(func, optContext) { + return function(elem) { + setTimeout(func.bind(optContext, elem), 0); + }; +}; + + +/** + * Call the given function with the given context when the element is cleaned up using + * ko.removeNode or ko.cleanNode. This may be used inline as an argument to dom(), without the + * first argument, to apply to the element being constructed. The function called will receive the + * element as the sole argument. + * @param {Node} elem Element whose destruction should trigger a call to func. It + * should be omitted when used as an argument to dom(). + * @param {Function} func Function to call, with elem as an argument, when elem is cleaned up. + * @param {Object} optContext Optionally `this` context to call the function with. + */ +dom.onDispose = dom.inlinable(function(elem, func, optContext) { + ko.utils.domNodeDisposal.addDisposeCallback(elem, func.bind(optContext)); +}); + + +/** + * Tie the disposal of the given value to the given element, so that value.dispose() gets called + * when the element is cleaned up using ko.removeNode or ko.cleanNode. This may be used inline as + * an argument to dom(), without the first argument, to apply to the element being constructed. + * @param {Node} elem Element whose destruction should trigger the disposal of the value. It + * should be omitted when used as an argument to dom(). + * @param {Object} disposableValue A value with a dispose() method, such as a computed observable. + */ +dom.autoDispose = dom.inlinable(function(elem, disposableValue) { + ko.utils.domNodeDisposal.addDisposeCallback(elem, function() { + disposableValue.dispose(); + }); +}); + + +/** + * Set an identifier for the given element for identifying the element in automated browser tests. + * @param {String} ident: Arbitrary string; convention is to name it as "ModuleName.nameInModule". + */ +dom.testId = dom.inlinable(function(elem, ident) { + elem.setAttribute('data-test-id', ident); +}); + +module.exports = dom; diff --git a/app/client/lib/domAsync.ts b/app/client/lib/domAsync.ts new file mode 100644 index 00000000..000e04cb --- /dev/null +++ b/app/client/lib/domAsync.ts @@ -0,0 +1,25 @@ +import {reportError} from 'app/client/models/errors'; +import {DomContents, onDisposeElem, replaceContent} from 'grainjs'; +// grainjs annoyingly doesn't export browserGlobals tools, useful for testing in a simulated environment. +import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; + +/** + * Insert DOM contents produced by a Promise. Until the Promise is fulfilled, nothing shows up. + * TODO: This would be a handy place to support options to show a loading spinner (perhaps + * showing up if the promise takes more than a bit to show). + */ +export function domAsync(promiseForDomContents: Promise, onError = reportError): DomContents { + const markerPre = G.document.createComment('a'); + const markerPost = G.document.createComment('b'); + + // Function is added after the markers, to run once they have been attached to elem (the parent). + return [markerPre, markerPost, (elem: Node) => { + let disposed = false; + promiseForDomContents + .then((contents) => disposed || replaceContent(markerPre, markerPost, contents)) + .catch(onError); + + // If markerPost is disposed before the promise resolves, set insertContent to noop. + onDisposeElem(markerPost, () => { disposed = true; }); + }]; +} diff --git a/app/client/lib/download.js b/app/client/lib/download.js new file mode 100644 index 00000000..9497181e --- /dev/null +++ b/app/client/lib/download.js @@ -0,0 +1,31 @@ +const G = require('../lib/browserGlobals').get('document'); +const dom = require('../lib/dom'); + +/** + * Note about testing + * It is difficult to test file downloads as Selenuim and javascript do not provide + * an easy way to control native dialogs. + * One approach would be to configure the test browser to automatically start the download and + * save the file in a specific place. Then check that the file exists at that location. + * Firefox documentation: http://kb.mozillazine.org/File_types_and_download_actions + * Approach detailed here in java: https://www.seleniumeasy.com/selenium-tutorials/verify-file-after-downloading-using-webdriver-java + */ + +let _download = null; +/** + * Trigger a download on the file at the given url. + * @param {String} href: The url of the download. + */ +function download(href) { + if (!_download) { + _download = dom('a', { + style: 'position: absolute; top: 0; display: none', + download: '' + }); + G.document.body.appendChild(_download); + } + _download.setAttribute('href', href); + _download.click(); +} + +module.exports = download; diff --git a/app/client/lib/fromKoSave.ts b/app/client/lib/fromKoSave.ts new file mode 100644 index 00000000..c7ae8c2d --- /dev/null +++ b/app/client/lib/fromKoSave.ts @@ -0,0 +1,32 @@ +/** + * Replicates some of grainjs's fromKo, except that the returned observables have a set() method + * which calls koObs.saveOnly(val) rather than koObs(val). + */ +import {IKnockoutObservable, KoWrapObs, Observable} from 'grainjs'; + +const wrappers: WeakMap, Observable> = new WeakMap(); + +/** + * Returns a Grain.js observable which mirrors a Knockout observable. + * + * Do not dispose this wrapper, as it is shared by all code using koObs, and its lifetime is tied + * to the lifetime of koObs. If unused, it consumes minimal resources, and should get garbage + * collected along with koObs. + */ +export function fromKoSave(koObs: IKnockoutObservable): Observable { + return wrappers.get(koObs) || wrappers.set(koObs, new KoSaveWrapObs(koObs)).get(koObs)!; +} + +export class KoSaveWrapObs extends KoWrapObs { + constructor(_koObs: IKnockoutObservable) { + if (!('saveOnly' in _koObs)) { + throw new Error('fromKoSave needs a saveable observable'); + } + super(_koObs); + } + + public set(value: T): void { + // Hacky cast to get a private member. TODO: should make it protected instead. + (this as any)._koObs.saveOnly(value); + } +} diff --git a/app/client/lib/guessTimezone.ts b/app/client/lib/guessTimezone.ts new file mode 100644 index 00000000..e13bb796 --- /dev/null +++ b/app/client/lib/guessTimezone.ts @@ -0,0 +1,11 @@ +import {loadMomentTimezone} from 'app/client/lib/imports'; + +/** + * Returns the browser timezone, using moment.tz.guess(), allowing overriding it via a "timezone" + * URL parameter, for the sake of tests. + */ +export async function guessTimezone() { + const moment = await loadMomentTimezone(); + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get('timezone') || moment.tz.guess(); +} diff --git a/app/client/lib/helpScout.ts b/app/client/lib/helpScout.ts new file mode 100644 index 00000000..2444bf92 --- /dev/null +++ b/app/client/lib/helpScout.ts @@ -0,0 +1,209 @@ +/** + * This module contains tools and helpers to open HelpScout "Beacon" -- a popup which may contain + * an email form, chat, and help docs -- and to include info relevant to support requests. + * + * Usage: + * import {Beacon} from 'app/client/lib/helpScout'; + * Beacon('open') + * Beacon('prefill', {...}) + * It takes care of initialization automatically. + * + * This is essentially a prettified typescript version of the snippet for the HelpScout Beacon + * available under Beacon settings in HelpScout. It offers the API documented at + * https://developer.helpscout.com/beacon-2/web/javascript-api/ + */ + +// tslint:disable:unified-signatures + +import {AppModel, reportError} from 'app/client/models/AppModel'; +import {IAppError} from 'app/client/models/NotifyModel'; +import {GristLoadConfig} from 'app/common/gristUrls'; +import {timeFormat} from 'app/common/timeFormat'; +import {ActiveSessionInfo} from 'app/common/UserAPI'; +import * as version from 'app/common/version'; +import {dom} from 'grainjs'; +import identity = require('lodash/identity'); +import pickBy = require('lodash/pickBy'); + +export type BeaconCmd = 'init' | 'destroy' | 'open' | 'close' | 'toggle' | 'search' | 'suggest' | + 'article' | 'navigate' | 'identify' | 'prefill' | 'reset' | 'logout' | 'config' | 'on' | 'off' | + 'once' | 'event' | 'session-data'; + +export interface IUserObj { + name?: string; + email?: string; + company?: string; + jobTitle?: string; + avatar?: string; + signature?: string; + [customKey: string]: string|number|boolean|null|undefined; +} + +interface IFormObj { + name?: string; + email?: string; + subject?: string; + text?: string; + fields?: Array<{id: number, value: string|number|boolean}>; +} + +interface ISessionData { + [key: string]: string; +} + +/** + * This provides the HelpScout Beacon API, taking care of initializing Beacon on first use. + */ +export function Beacon(method: 'init', beaconId: string): void; +export function Beacon(method: 'search', query: string): void; +export function Beacon(method: 'suggest', articles?: string[]): void; +export function Beacon(method: 'article', articleId: string, options?: unknown): void; +export function Beacon(method: 'navigate', route: string): void; +export function Beacon(method: 'identify', userObj: IUserObj): void; +export function Beacon(method: 'prefill', formObj: IFormObj): void; +export function Beacon(method: 'config', configObj: object): void; +export function Beacon(method: 'on'|'off'|'once', event: 'open'|'close', callback: () => void): void; +export function Beacon(method: 'session-data', data: ISessionData): void; +export function Beacon(method: BeaconCmd): void; +export function Beacon(method: BeaconCmd, options?: unknown, data?: unknown) { + initBeacon(); + (window as any).Beacon(method, options, data); +} + +function _beacon(method: BeaconCmd, options?: unknown, data?: unknown) { + _beacon.readyQueue.push({method, options, data}); +} +_beacon.readyQueue = [] as unknown[]; + +function initBeacon(): void { + if (!(window as any).Beacon) { + const gristConfig: GristLoadConfig|undefined = (window as any).gristConfig; + const beaconId = gristConfig && gristConfig.helpScoutBeaconId; + if (beaconId) { + (window as any).Beacon = _beacon; + document.head.appendChild(dom('script', { + type: 'text/javascript', + src: 'https://beacon-v2.helpscout.net', + async: true, + })); + _beacon('init', beaconId); + _beacon('config', {display: {style: "manual"}}); + } else { + (window as any).Beacon = () => null; + reportError(new Error("Support form is not configured")); + } + } +} + +let lastOpenType: 'error' | 'message' = 'message'; + +/** + * Helper to open a beacon, taking care of setting focus appropriately. Calls optional onOpen + * callback when the beacon has opened. + * If errors is given, prepares a form for submitting an error report, and includes stack traces + * into the session-data. + */ +function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, errors?: IAppError[]}) { + const {onOpen, errors} = options; + + // The beacon remembers its content, so reset it when switching between reporting errors and + // sending a message. + const openType = errors ? 'error' : 'message'; + if (openType !== lastOpenType) { + Beacon('reset'); + lastOpenType = openType; + } + + Beacon('once', 'open', () => { + const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement; + if (iframe) { iframe.focus(); } + if (onOpen) { onOpen(); } + }); + Beacon('once', 'close', () => { + const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement; + if (iframe) { iframe.blur(); } + }); + if (userObj) { + Beacon('identify', userObj); + } + + const attrs: ISessionData = {}; + if (errors) { + // If sending errors, prefill part of the message (the user sees this and can add to it), and + // include more detailed errors with stack traces into session-data. + const messages = errors.map(({error, timestamp}) => + (timeFormat('T', new Date(timestamp)) + ' ' + error.message)); + const lastMessage = errors.length > 0 ? errors[errors.length - 1].error.message : ''; + const prefill: IFormObj = { + subject: `Application Error: ${lastMessage}`.slice(0, 250), // subject has max-length of 250 + text: `\n-- Include your description above --\nErrors encountered:\n${messages.join('\n')}\n`, + }; + Beacon('prefill', prefill); + Beacon('config', {messaging: {contactForm: {showSubject: false}}}); + + errors.forEach(({error, timestamp}, i) => { + attrs[`error-${i}`] = timeFormat('D T', new Date(timestamp)) + ' ' + error.message; + if (error.stack) { + attrs[`error-${i}-stack`] = JSON.stringify(error.stack.trim().split('\n')); + } + }); + } else { + Beacon('config', {messaging: {contactForm: {showSubject: true}}}); + } + + Beacon('session-data', { + 'Grist Version': `${version.version} (${version.gitcommit})`, + ...attrs, + }); + Beacon('open'); + Beacon('navigate', '/ask/message/'); +} + +export interface IBeaconOpenOptions { + appModel: AppModel|null; + includeAppErrors?: boolean; + onOpen?: () => void; + errors?: IAppError[]; +} + +/** + * Open the helpScout beacon to send us a message. Calls optional onOpen callback when the beacon + * has opened. The topAppModel is used to get the current user. + * + * If includeAppErrors or errors is set, the beacon will open to submit an error report. With + * includeAppErrors, it will include stack traces of errors in the notifier into the session-data. + * If errors is set, it will include the specified errors. + */ +export function beaconOpenMessage(options: IBeaconOpenOptions) { + const app = options.appModel; + const errors = options.errors || []; + if (options.includeAppErrors && app) { + errors.push(...app.notifier.getFullAppErrors()); + } + _beaconOpen(getBeaconUserObj(app), options); +} + + +function getBeaconUserObj(appModel: AppModel|null): IUserObj|null { + if (!appModel) { return null; } + + // ActiveSessionInfo["user"] includes optional helpScoutSignature too. + const user = appModel.currentValidUser as ActiveSessionInfo["user"]|null; + + // For anon user, don't attempt to identify anything. Even the "company" field (when anon on a + // team doc) isn't useful, because the user may be external to the company. + if (!user) { return null; } + + // Use the company name only when it's not a personal org. Otherwise, it adds no information and + // overrides more useful company name gleaned by HelpScout from the web. + const org = appModel.currentOrg; + const company = org && !org.owner ? appModel.currentOrgName : undefined; + + return pickBy({ + name: user.name, + email: user.email, + company, + avatar: user.picture, + signature: user.helpScoutSignature, + }, identity); +} diff --git a/app/client/lib/imports.d.ts b/app/client/lib/imports.d.ts new file mode 100644 index 00000000..11bbc110 --- /dev/null +++ b/app/client/lib/imports.d.ts @@ -0,0 +1,20 @@ +import * as BillingPageModule from 'app/client/ui/BillingPage'; +import * as GristDocModule from 'app/client/components/GristDoc'; +import * as SearchBarModule from 'app/client/components/SearchBar'; +import * as ViewPane from 'app/client/components/ViewPane'; +import * as UserManagerModule from 'app/client/ui/UserManager'; +import * as searchModule from 'app/client/ui2018/search'; +import * as momentTimezone from 'moment-timezone'; +import * as plotly from 'plotly.js'; + +export type PlotlyType = typeof plotly; +export type MomentTimezone = typeof momentTimezone; + +export function loadBillingPage(): Promise; +export function loadGristDoc(): Promise; +export function loadMomentTimezone(): Promise; +export function loadPlotly(): Promise; +export function loadSearch(): Promise; +export function loadSearchBar(): Promise; +export function loadUserManager(): Promise; +export function loadViewPane(): Promise; diff --git a/app/client/lib/imports.js b/app/client/lib/imports.js new file mode 100644 index 00000000..33c66911 --- /dev/null +++ b/app/client/lib/imports.js @@ -0,0 +1,16 @@ +/** + * + * Dynamic imports from js work fine with webpack; from typescript we need to upgrade + * our "module" setting, which has a lot of knock-on effects. To work around that for + * the moment, importing can be done from this js file. + * + */ + +exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */); +exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */); +exports.loadMomentTimezone = () => import('moment-timezone'); +exports.loadPlotly = () => import('plotly.js-basic-dist' /* webpackChunkName: "plotly" */); +exports.loadSearchBar = () => import('app/client/components/SearchBar' /* webpackChunkName: "searchbar" */); +exports.loadSearch = () => import('app/client/ui2018/search' /* webpackChunkName: "search" */); +exports.loadUserManager = () => import('app/client/ui/UserManager' /* webpackChunkName: "usermanager" */); +exports.loadViewPane = () => import('app/client/components/ViewPane' /* webpackChunkName: "viewpane" */); diff --git a/app/client/lib/koArray.d.ts b/app/client/lib/koArray.d.ts new file mode 100644 index 00000000..8d63564c --- /dev/null +++ b/app/client/lib/koArray.d.ts @@ -0,0 +1,31 @@ +import * as ko from 'knockout'; + +declare class KoArray { + public static syncedKoArray(...args: any[]): any; + public peekLength: number; + public subscribe: ko.Observable["subscribe"]; + + public dispose(): void; + public at(index: number): T|null; + public all(): T[]; + public map(op: (x: T) => T2): KoArray; + public peek(): T[]; + public getObservable(): ko.Observable; + public push(...items: T[]): void; + public unshift(...items: T[]): void; + public assign(newValues: T[]): void; + public splice(start: number, optDeleteCount?: number, ...values: T[]): T[]; + public subscribeForEach(options: { + add?: (item: T, index: number, arr: KoArray) => void; + remove?: (item: T, arr: KoArray) => void; + addDelay?: number; + }): ko.Subscription; + + public clampIndex(index: number): number|null; + public makeLiveIndex(index?: number): ko.Observable & {setLive(live: boolean): void}; + public setAutoDisposeValues(): this; +} + +declare function syncedKoArray(...args: any[]): any; + +export default function koArray(initialValue?: T[]): KoArray; diff --git a/app/client/lib/koArray.js b/app/client/lib/koArray.js new file mode 100644 index 00000000..22e1b96b --- /dev/null +++ b/app/client/lib/koArray.js @@ -0,0 +1,457 @@ +/** + * Our version of knockout's ko.observableArray(), similar but more efficient. It + * supports fewer methods (mainly because we don't need other methods at the moment). Instead of + * emitting 'arrayChange' events, it emits 'spliceChange' events. + */ + + +var ko = require('knockout'); +var Promise = require('bluebird'); +var dispose = require('./dispose'); +var gutil = require('app/common/gutil'); + +require('./koUtil'); // adds subscribeInit method to observables. + +/** + * Event indicating that a koArray has been modified. This reflects changes to which objects are + * in the array, not the state of those objects. A `spliceChange` event is emitted after the array + * has been modified. + * @event spliceChange + * @property {Array} data - The underlying array, already modified. + * @property {Number} start - The start index at which items were inserted or deleted. + * @property {Number} added - The number of items inserted. + * @property {Array} deleted - The array of items that got deleted. + */ + +/** + * Creates and returns a new koArray, either empty or with the given initial values. + * Unlike a ko.observableArray(), you access the values using array.all(), and set values using + * array.assign() (or better, by using push() and splice()). + */ +function koArray(optInitialValues) { + return KoArray.create(optInitialValues); +} + +// The koArray function is the main export. +module.exports = exports = koArray; +exports.default = koArray; + +/** + * Checks if an object is an instance of koArray. + */ +koArray.isKoArray = function(obj) { + return (obj && typeof obj.subscribe === 'function' && typeof obj.all === 'function'); +}; +exports.isKoArray = koArray.isKoArray; + +/** + * Given an observable which evaluates to different arrays or koArrays, returns a single koArray + * observable which mirrors whichever array is the current value of the observable. If a callback + * is given, all elements are mapped through it. See also map(). + * @param {ko.observable} koArrayObservable: observable whose value is a koArray or plain array. + * @param {Function} optCallback: If given, maps elements from original arrays. + * @param {Object} optCallbackTarget: If callback is given, this becomes the `this` value for it. + * @returns {koArray} a single koArray that mirrors the current value of koArrayObservable, + * optionally mapping them through optCallback. + */ +koArray.syncedKoArray = function(koArrayObservable, optCallback, optCallbackTarget) { + var ret = koArray(); + optCallback = optCallback || identity; + ret.autoDispose(koArrayObservable.subscribeInit(function(currentArray) { + if (koArray.isKoArray(currentArray)) { + ret.syncMap(currentArray, optCallback, optCallbackTarget); + } else if (currentArray) { + ret.syncMapDisable(); + ret.assign(currentArray.map(function(item, i) { + return optCallback.call(optCallbackTarget, item, i); + })); + } + })); + return ret; +}; +exports.syncedKoArray = koArray.syncedKoArray; + + +function SyncedState(constructFunc, key) { + constructFunc(this, key); +} +dispose.makeDisposable(SyncedState); + +/** + * Create and return a new Map that's kept in sync with koArrayObj. The keys are the array items + * themselves. The values are constructed using constructFunc(state, item), where state is a new + * Disposable object, allowing to associate other disposable state with the item. The returned Map + * should itself be disposed when no longer needed. + * @param {KoArray} koArrayObj: A KoArray object to watch. + * @param {Function} constructFunc(state, item): called for each item in the array, with a new + * disposable state object, on which all Disposable methods are available. The state object + * will be disposed when an item is removed or the returned map itself disposed. + * @param [Number] options.addDelay: (optional) If numeric, delay calls to add items + * by this many milliseconds (except initialization, which is always immediate). + * @return {Map} map object mapping array items to state objects, and with a dispose() method. + */ +koArray.syncedMap = function(koArrayObj, constructFunc, options) { + var map = new Map(); + var sub = koArrayObj.subscribeForEach({ + add: item => map.set(item, SyncedState.create(constructFunc, item)), + remove: item => gutil.popFromMap(map, item).dispose(), + addDelay: options && options.addDelay + }); + map.dispose = () => { + sub.dispose(); + map.forEach((stateObj, item) => stateObj.dispose()); + }; + return map; +}; + + +/** + * The actual constructor for koArray. To create a new instance, simply use koArray() (without + * `new`). The constructor might be needed, however, to inherit from this class. + */ +function KoArray(initialValues) { + this._array = ko.observable(initialValues || []); + this._preparedSpliceEvent = null; + this._syncSubscription = null; + this._disposeElements = noop; + + this.autoDispose(this._array.subscribe(this._emitPreparedEvent, this, 'spectate')); + + this.autoDisposeCallback(function() { + this._disposeElements(this.peek()); + }); +} +exports.KoArray = KoArray; + +dispose.makeDisposable(KoArray); + +/** + * If called on a koArray, it will dispose of its contained items as they are removed or when the + * array is itself disposed. + * @returns {koArray} itself. + */ +KoArray.prototype.setAutoDisposeValues = function() { + this._disposeElements = this._doDisposeElements; + return this; +}; + +/** + * Returns the underlying array, creating a dependency when used from a computed observable. + * Note that you must not modify the returned array directly; you should use koArray methods. + */ +KoArray.prototype.all = function() { + return this._array(); +}; + +/** + * Returns the underlying array without creating a dependency on it. + * Note that you must not modify the returned array directly; you should use koArray methods. + */ +KoArray.prototype.peek = function() { + return this._array.peek(); +}; + +/** + * Returns the underlying observable whose value is a plain array. + */ +KoArray.prototype.getObservable = function() { + return this._array; +}; + +/** + * The `peekLength` property evaluates to the length of the underlying array. Using it does NOT + * create a dependency on the array. Use array.all().length to create a dependency. + */ +Object.defineProperty(KoArray.prototype, 'peekLength', { + configurable: false, + enumerable: false, + get: function() { return this._array.peek().length; }, +}); + +/** + * A shorthand for the itemModel at a given index. Returns null if the index is invalid or out of + * range. Create a dependency on the array itself. + */ +KoArray.prototype.at = function(index) { + var arr = this._array(); + return index >= 0 && index < arr.length ? arr[index] : null; +}; + +/** + * Assigns a new underlying array. This is analogous to observableArray(newValues). + */ +KoArray.prototype.assign = function(newValues) { + var oldArray = this.peek(); + this._prepareSpliceEvent(0, newValues.length, oldArray); + this._array(newValues.slice()); + this._disposeElements(oldArray); +}; + + +/** + * Subscribe to events for this koArray. To be notified of splice details, subscribe to + * 'spliceChange', which will always follow the plain 'change' events. + */ +KoArray.prototype.subscribe = function(callback, callbackTarget, event) { + return this._array.subscribe(callback, callbackTarget, event); +}; + + +/** + * @private + * Internal method to prepare a 'spliceChange' event. + */ +KoArray.prototype._prepareSpliceEvent = function(start, numAdded, deleted) { + this._preparedSpliceEvent = { + array: null, + start: start, + added: numAdded, + deleted: deleted + }; +}; + +/** + * @private + * Internal method to emit and reset a prepared 'spliceChange' event, if there is one. + */ +KoArray.prototype._emitPreparedEvent = function() { + var event = this._preparedSpliceEvent; + if (event) { + event.array = this.peek(); + this._preparedSpliceEvent = null; + this._array.notifySubscribers(event, 'spliceChange'); + } +}; + +/** + * @private + * Internal method called before the underlying array is modified. This copies how knockout emits + * its default events internally. + */ +KoArray.prototype._preChange = function() { + this._array.valueWillMutate(); +}; + +/** + * @private + * Internal method called before the underlying array is modified. This copies how knockout emits + * its default events internally. + */ +KoArray.prototype._postChange = function() { + this._array.valueHasMutated(); +}; + +/** + * @private + * Internal method to call dispose() for each item in the passed-in array. It's only used when + * autoDisposeValues option is given to koArray. + */ +KoArray.prototype._doDisposeElements = function(elements) { + for (var i = 0; i < elements.length; i++) { + elements[i].dispose(); + } +}; + +/** + * The standard array `push` method, which emits all expected events. + */ +KoArray.prototype.push = function() { + var array = this.peek(); + var start = array.length; + + this._preChange(); + var ret = array.push.apply(array, arguments); + this._prepareSpliceEvent(start, arguments.length, []); + this._postChange(); + return ret; +}; + +/** + * The standard array `unshift` method, which emits all expected events. + */ +KoArray.prototype.unshift = function() { + var array = this.peek(); + this._preChange(); + var ret = array.unshift.apply(array, arguments); + this._prepareSpliceEvent(0, arguments.length, []); + this._postChange(); + return ret; +}; + +/** + * The standard array `splice` method, which emits all expected events. + */ +KoArray.prototype.splice = function(start, optDeleteCount) { + return this.arraySplice(start, optDeleteCount, Array.prototype.slice.call(arguments, 2)); +}; + +KoArray.prototype.arraySplice = function(start, optDeleteCount, arrToInsert) { + var array = this.peek(); + var len = array.length; + var startIndex = Math.min(len, Math.max(0, start < 0 ? len + start : start)); + + this._preChange(); + var ret = (optDeleteCount === void 0 ? array.splice(start) : + array.splice(start, optDeleteCount)); + gutil.arraySplice(array, startIndex, arrToInsert); + this._prepareSpliceEvent(startIndex, arrToInsert.length, ret); + this._postChange(); + this._disposeElements(ret); + return ret; +}; + +/** + * The standard array `slice` method. Creates a dependency when used from a computed observable. + */ +KoArray.prototype.slice = function() { + var array = this.all(); + return array.slice.apply(array, arguments); +}; + + +/** + * Returns a new KoArray instance, subscribed to the current one to stay parallel to it. The new + * element are set to the result of calling `callback(orig, i)` on each original element. Note + * that the index argument is only correct as of the time the callback got called. + */ +KoArray.prototype.map = function(callback, optThis) { + var newArray = new KoArray(); + newArray.syncMap(this, callback, optThis); + return newArray; +}; + + +function noop() {} +function identity(x) { return x; } + +/** + * Keep this array in sync with another koArray, optionally mapping all elements through the given + * callback. If callback is omitted, the current array will just mirror otherKoArray. + * See also map(). + * + * The subscription is disposed when the koArray is disposed. + */ +KoArray.prototype.syncMap = function(otherKoArray, optCallback, optCallbackTarget) { + this.syncMapDisable(); + + optCallback = optCallback || identity; + + this.assign(otherKoArray.peek().map(function(item, i) { + return optCallback.call(optCallbackTarget, item, i); + })); + + this._syncSubscription = this.autoDispose(otherKoArray.subscribe(function(splice) { + var arr = splice.array; + var newValues = []; + for (var i = splice.start, n = 0; n < splice.added; i++, n++) { + newValues.push(optCallback.call(optCallbackTarget, arr[i], i)); + } + this.arraySplice(splice.start, splice.deleted.length, newValues); + }, this, 'spliceChange')); +}; + +/** + * Disable previously created syncMap subscription, if any. + */ +KoArray.prototype.syncMapDisable = function() { + if (this._syncSubscription) { + this.disposeDiscard(this._syncSubscription); + this._syncSubscription = null; + } +}; + + +/** + * Analog to forEach for regular arrays, but that stays in sync with array changes. + * @param {Function} options.add: func(item, index, koarray) is called for each item present, + * and whenever an item is added. + * @param {Function} options.remove: func(item, koarray) is called whenever an item is removed. + * @param [Object] options.context: (optional) `this` value to use in add/remove callbacks. + * @param [Number] options.addDelay: (optional) If numeric, delay calls to the add + * callback by this many milliseconds (except initialization calls which are always immediate). + */ +KoArray.prototype.subscribeForEach = function(options) { + var context = options.context; + var onAdd = options.add || noop; + var onRemove = options.remove || noop; + var shouldDelay = (typeof options.addDelay === 'number'); + + var subscription = this.subscribe(function(splice) { + var i, arr = splice.array; + for (i = 0; i < splice.deleted.length; i++) { + onRemove.call(context, splice.deleted[i], this); + } + var callAdd = () => { + var end = splice.start + splice.added; + for (i = splice.start; i < end; i++) { + onAdd.call(context, arr[i], i, this); + } + }; + if (!shouldDelay) { + callAdd(); + } else if (options.addDelay > 0) { + setTimeout(callAdd, options.addDelay); + } else { + // Promise library invokes the callback much sooner than setTimeout does, i.e. it's much + // closer to "nextTick", which is what we want here. + Promise.resolve(null).then(callAdd); + } + }, this, 'spliceChange'); + + this.peek().forEach(function(item, i) { + onAdd.call(context, item, i, this); + }, this); + + return subscription; +}; + +/** + * Given a numeric index, returns an index that's valid for this array, clamping it if needed. + * If the array is empty, returns null. If the index given is null, treats it as 0. + */ +KoArray.prototype.clampIndex = function(index) { + var len = this.peekLength; + return len === 0 ? null : gutil.clamp(index || 0, 0, len - 1); +}; + +/** + * Returns a new observable representing an index into this array. It can be read and written, and + * its value is clamped to be a valid index. The index is only null if the array is empty. + * + * As the array changes, the index is adjusted to continue pointing to the same element. If the + * pointed element is deleted, the index is adjusted to after the deletion point. + * + * The returned observable has an additional .setLive(bool) method. While set to false, the + * observale will not be adjusted as the array changes, except to keep it valid. + */ +KoArray.prototype.makeLiveIndex = function(optInitialIndex) { + // The underlying observable index. Not exposed directly. + var index = ko.observable(this.clampIndex(optInitialIndex)); + var isLive = true; + + // Adjust the index when data is spliced before it. + this.subscribe(function(splice) { + var idx = index.peek(); + if (!isLive) { + index(this.clampIndex(idx)); + } else if (idx === null) { + index(this.clampIndex(0)); + } else if (idx >= splice.start + splice.deleted.length) { + // Adjust the index if it was beyond the deleted region. + index(this.clampIndex(idx + splice.added - splice.deleted.length)); + } else if (idx >= splice.start + splice.added) { + // Adjust the index if it was inside the deleted region (and not replaced). + index(this.clampIndex(splice.start + splice.added)); + } + }, this, 'spliceChange'); + + // The returned value, which is a writable computable, constraining the value to the valid range + // (or null if the range is empty). + var ret = ko.pureComputed({ + read: index, + write: function(val) { index(this.clampIndex(val)); }, + owner: this + }); + ret.setLive = (val => { isLive = val; }); + return ret; +}; diff --git a/app/client/lib/koArrayWrap.ts b/app/client/lib/koArrayWrap.ts new file mode 100644 index 00000000..18d3426a --- /dev/null +++ b/app/client/lib/koArrayWrap.ts @@ -0,0 +1,42 @@ +import {KoArray} from 'app/client/lib/koArray'; +import {IDisposableOwnerT, MutableObsArray, ObsArray, setDisposeOwner} from 'grainjs'; + +/** + * Returns a grainjs ObsArray that reflects the given koArray, mapping small changes using + * similarly efficient events. + * + * (Note that for both ObsArray and koArray, the main purpose in life is to be more efficient than + * an array-valued observable by handling small changes more efficiently.) + */ +export function createObsArray( + owner: IDisposableOwnerT> | null, + koArray: KoArray, +): ObsArray { + return setDisposeOwner(owner, new KoWrapObsArray(koArray)); +} + + +/** + * An Observable that wraps a Knockout observable, created via fromKo(). It keeps minimal overhead + * when unused by only subscribing to the wrapped observable while it itself has subscriptions. + * + * This way, when unused, the only reference is from the wrapper to the wrapped object. KoWrapObs + * should not be disposed; its lifetime is tied to that of the wrapped object. + */ +class KoWrapObsArray extends MutableObsArray { + private _koSub: any = null; + + constructor(_koArray: KoArray) { + super(Array.from(_koArray.peek())); + + this._koSub = _koArray.subscribe((splice: any) => { + const newValues = splice.array.slice(splice.start, splice.start + splice.added); + this.splice(splice.start, splice.deleted.length, ...newValues); + }, null, 'spliceChange'); + } + + public dispose(): void { + this._koSub.dispose(); + super.dispose(); + } +} diff --git a/app/client/lib/koDom.js b/app/client/lib/koDom.js new file mode 100644 index 00000000..7bdd5285 --- /dev/null +++ b/app/client/lib/koDom.js @@ -0,0 +1,426 @@ +/** + * koDom.js is an analog to Knockout.js bindings that works with our dom.js library. + * koDom provides a suite of bindings between the DOM and knockout observables. + * + * For example, here's how we can create som DOM with some bindings, given a view-model object vm: + * dom( + * 'div', + * kd.toggleClass('active', vm.isActive), + * kd.text(function() { + * return vm.data()[vm.selectedRow()][part.value.index()]; + * }) + * ); + */ + + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +var G = require('./browserGlobals').get('document', 'Node'); + +var ko = require('knockout'); +var dom = require('./dom'); +var koArray = require('./koArray'); + +/** + * Creates a binding between a DOM element and an observable value, making sure that + * updaterFunc(elem, value) is called whenever the observable changes. It also registers disposal + * callbacks on the element so that the binding is cleared when the element is disposed with + * ko.cleanNode() or ko.removeNode(). + * + * @param {Node} elem: DOM element. + * @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with), + * or a constant value. + * @param {Function} updaterFunc: Called both initially and whenever the value changes as + * updaterFunc(elem, value). The value is already unwrapped (so is not an observable). + */ +function setBinding(elem, valueOrFunc, updaterFunc) { + var subscription; + if (ko.isObservable(valueOrFunc)) { + subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); }); + ko.utils.domNodeDisposal.addDisposeCallback(elem, function() { + subscription.dispose(); + }); + updaterFunc(elem, valueOrFunc.peek()); + } else if (typeof valueOrFunc === 'function') { + valueOrFunc = ko.computed(valueOrFunc); + subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); }); + ko.utils.domNodeDisposal.addDisposeCallback(elem, function() { + subscription.dispose(); + valueOrFunc.dispose(); + }); + updaterFunc(elem, valueOrFunc.peek()); + } else { + updaterFunc(elem, valueOrFunc); + } +} +exports.setBinding = setBinding; + +/** + * Internal helper to create a binding. Used by most simple bindings. + * @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with), + * or a constant value. + * @param {Function} updaterFunc: Called both initially and whenever the value changes as + * updaterFunc(elem, value). The value is already unwrapped (so is not an observable). + * @returns {Function} Function suitable to pass as an argument to dom(); i.e. one that takes an + * DOM element, and adds the bindings to it. It also registers disposal callbacks on the + * element, so that bindings are cleaned up when the element is disposed with ko.cleanNode() + * or ko.removeNode(). + */ +function makeBinding(valueOrFunc, updaterFunc) { + return function(elem) { + setBinding(elem, valueOrFunc, updaterFunc); + }; +} +exports.makeBinding = makeBinding; + +/** + * Keeps the text content of a DOM element in sync with an observable value. + * Just like knockout's `text` binding. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + */ +function text(valueOrFunc) { + return function(elem) { + // Since setting textContent property of an element removes all its other children, we insert + // a new text node, and change the content of that. However, we tie the binding to the parent + // elem, i.e. make it disposed along with elem, because text nodes don't get cleaned by + // ko.removeNode / ko.cleanNode. + var textNode = G.document.createTextNode(""); + setBinding(elem, valueOrFunc, function(elem, value) { + textNode.nodeValue = value; + }); + + elem.appendChild(textNode); + }; +} +exports.text = text; + +// Used for replacing the static token span created by bootstrap tokenfield with the the same token +// but with its text content tied to an observable. +// To use bootstrapToken: +// 1) Get the token to make a clone of and the observable desired. +// 2) Create the new token by calling this function. +// 3) Replace the original token with this newly created token in the DOM by doing +// Ex: var newToken = bootstrapToken(originalToken, observable); +// parentElement.replaceChild(originalToken, newToken); +// TODO: Make templateToken optional. If not given, bind the observable to a manually created token. +function bootstrapToken(templateToken, valueOrFunc) { + var clone = templateToken.cloneNode(); + setBinding(clone, valueOrFunc, function(e, value) { + clone.textContent = value; + }); + return clone; +} +exports.bootstrapToken = bootstrapToken; + +/** + * Keeps the attribute `attrName` of a DOM element in sync with an observable value. + * Just like knockout's `attr` binding. Removes the attribute when the value is null or undefined. + * @param {String} attrName The name of the attribute to bind, e.g. 'href'. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + */ +function attr(attrName, valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, value) { + if (value === null || value === undefined) { + elem.removeAttribute(attrName); + } else { + elem.setAttribute(attrName, value); + } + }); +} +exports.attr = attr; + + +/** + * Keeps the style property `property` of a DOM element in sync with an observable value. + * Just like knockout's `style` binding. + * @param {String} property The name of the style property to bind, e.g. 'fontWeight'. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + */ +function style(property, valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, value) { + elem.style[property] = value; + }); +} +exports.style = style; + + +/** + * Shows or hides the element depending on a boolean value. Note that the element must be visible + * initially (i.e. unsetting style.display should show it). + * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed + * observable. The value is treated as a boolean. + */ +function show(boolValueOrFunc) { + return makeBinding(boolValueOrFunc, function(elem, value) { + elem.style.display = value ? '' : 'none'; + }); +} +exports.show = show; + +/** + * The opposite of show, equivalent to show(function() { return !value(); }). + * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed + * observable. The value is treated as a boolean. + */ +function hide(boolValueOrFunc) { + return makeBinding(boolValueOrFunc, function(elem, value) { + elem.style.display = value ? 'none' : ''; + }); +} +exports.hide = hide; + +/** + * Associates some data with the DOM element, using ko.utils.domData. + */ +function domData(key, valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, value) { + ko.utils.domData.set(elem, key, value); + }); +} +exports.domData = domData; + +/** + * Keeps the value of the given DOM form element in sync with an observable value. + * Just like knockout's `value` binding, except that it is one-directional (for now). + */ +function value(valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, value) { + // This conditional shouldn't be necessary, but on Electron 1.7, + // setting unchanged value cause cursor to jump + if (elem.value !== value) { elem.value = value; } + }); +} +exports.value = value; + +/** + * Toggles a css class `className` according to the truthiness of an observable value. + * Similar to knockout's `css` binding with a static class. + * @param {String} className The name of the class to toggle. + * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed + * observable. The value is treated as a boolean. + */ +function toggleClass(className, boolValueOrFunc) { + return makeBinding(boolValueOrFunc, function(elem, value) { + elem.classList.toggle(className, !!value); + }); +} +exports.toggleClass = toggleClass; + + +/** + * Toggles the `disabled` attribute on when boolValueOrFunc evaluates true. When + * it evaluates false, the attribute is removed. + * @param {[type]} boolValueOrFunc boolValueOrFunc An observable, a constant, or a function for a computed + * observable. The value is treated as a boolean. + */ +function toggleDisabled(boolValueOrFunc) { + return makeBinding(boolValueOrFunc, function(elem, disabled) { + if (disabled) { + elem.setAttribute('disabled', 'disabled'); + } else { + elem.removeAttribute('disabled'); + } + }); +} +exports.toggleDisabled = toggleDisabled; + +/** + * Adds a css class named by an observable value. If the value changes, the previous class will be + * removed and the new one added. The value may be empty to avoid adding any class. + * Similar to knockout's `css` binding with a dynamic class. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + */ +function cssClass(valueOrFunc) { + var prevClass; + return makeBinding(valueOrFunc, function(elem, value) { + if (prevClass) { + elem.classList.remove(prevClass); + } + prevClass = value; + if (value) { + elem.classList.add(value); + } + }); +} +exports.cssClass = cssClass; + +/** + * Scrolls a child element into view. The value should be the index of the child element to + * consider. This function supports scrolly, and is mainly useful for scrollable container + * elements, with a `foreach` or a `scrolly` inside. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable + * whose value is the index of the child element to keep scrolled into view. + */ +function scrollChildIntoView(valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, index) { + if (index === null) { + return; + } + var scrolly = ko.utils.domData.get(elem, "scrolly"); + if (scrolly) { + // Delay this in case it's triggered while other changes are processed (e.g. splices). + setTimeout(() => scrolly.isDisposed() || scrolly.scrollRowIntoView(index), 0); + } else { + var child = elem.children[index]; + if (!child) { + return; + } + if (index === 0) { + // Scroll the container all the way if showing the first child. + elem.scrollTop = 0; + } + var childRect = child.getBoundingClientRect(); + var parentRect = elem.getBoundingClientRect(); + if (childRect.top < parentRect.top) { + child.scrollIntoView(true); // Align with top if scrolling up.. + } else if (childRect.bottom > parentRect.bottom) { + child.scrollIntoView(false); // ..bottom if scrolling down. + } + } + }); +} +exports.scrollChildIntoView = scrollChildIntoView; + + +/** + * Adds to a DOM element the content returned by `contentFunc` called with the value of the given + * observable. The content may be a Node, an array of Nodes, text, null or undefined. + * Similar to knockout's `with` binding. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + * @param {Function} contentFunc Called with the value of `valueOrFunc` whenever that value + * changes. The returned content will replace previous content among the children of the bound + * DOM element in the place where the scope() call was present among arguments to dom(). + */ +function scope(valueOrFunc, contentFunc) { + var marker, contentNodes = []; + return makeBinding(valueOrFunc, function(elem, value) { + // We keep a comment marker, so that we know where to insert the content, and numChildren, so + // that we know how many children are part of that content. + if (!marker) { + marker = elem.appendChild(G.document.createComment("")); + } + + // Create the new content before destroying the old, so that it is OK for the new content to + // include the old (by reattaching inside the new content). If we did it after, the old + // content would get destroyed before it gets moved. (Note that "destroyed" here means + // clearing associated bindings and event handlers, so it's not easily visible.) + var content = dom.frag(contentFunc(value)); + + // Remove any children added last time, cleaning associated data. + for (var i = 0; i < contentNodes.length; i++) { + if (contentNodes[i].parentNode === elem) { + ko.removeNode(contentNodes[i]); + } + } + contentNodes.length = 0; + var next = marker.nextSibling; + elem.insertBefore(content, next); + // Any number of children may have gotten added if content was a DocumentFragment. + for (var n = marker.nextSibling; n !== next; n = n.nextSibling) { + contentNodes.push(n); + } + }); +} +exports.scope = scope; + + +/** + * Conditionally adds to a DOM element the content returned by `contentFunc()` depending on the + * boolean value of the given observable. The content may be a Node, an array of Nodes, text, null + * or undefined. + * Similar to knockout's `if` binding. + * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed + * observable. The value is checked as a boolean, and passed to the content function. + * @param {Function} contentFunc A function called with the value of `boolValueOrFunc` whenever + * the observable changes from false to true. The returned content is added to the bound DOM + * element in the place where the maybe() call was present among arguments to dom(). + */ +function maybe(boolValueOrFunc, contentFunc) { + return scope(boolValueOrFunc, function(yesNo) { + return yesNo ? contentFunc(yesNo) : null; + }); +} +exports.maybe = maybe; + + +/** + * Observes an observable array (koArray), and creates and adds as many children to the bound DOM + * element as there are items in it. As the array is changed, children are added or removed. Also + * works for a plain data array, creating a static list of children. + * + * Elements are typically added and removed by splicing data into or out of the data koArray. When + * an item is removed, the corresponding node is removed using ko.removeNode (which also runs any + * disposal tied to the node). If the caller retains a reference to a Node item, and removes it + * from its parent, foreach will cope with it fine, but will not call ko.removeNode on that node + * when the item from which it came is spliced out. + * + * @param {koArray} data An koArray instance. + * @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for each + * array element. Note: `index` is not passed to itemCreateFunc as it is only correct at the + * time the item is created, and does not reflect further changes to the array. + */ +function foreach(data, itemCreateFunc) { + var marker; + var children = []; + return function(elem) { + if (!marker) { + marker = elem.appendChild(G.document.createComment("")); + } + var spliceFunc = function(splice) { + var i, start = splice.start; + + // Remove the elements that are gone. + var deletedElems = children.splice(start, splice.deleted.length); + for (i = 0; i < deletedElems.length; i++) { + // Some nodes may be null, or may have been removed elsewhere in the program. The latter + // are no longer our responsibility, and we should not clean them up. + if (deletedElems[i] && deletedElems[i].parentNode === elem) { + ko.removeNode(deletedElems[i]); + } + } + + if (splice.added > 0) { + // Create and insert new elements. + var frag = G.document.createDocumentFragment(); + var spliceArgs = [start, 0]; + for (i = 0; i < splice.added; i++) { + var itemModel = splice.array[start + i]; + var insertEl = itemCreateFunc(itemModel); + if (insertEl) { + ko.utils.domData.set(insertEl, "itemModel", itemModel); + frag.appendChild(insertEl); + } + spliceArgs.push(insertEl); + } + + // Add new elements to the children array we maintain. + Array.prototype.splice.apply(children, spliceArgs); + + // Find a valid child immediately preceding the start of the splice, for DOM insertion. + var baseElem = marker; + for (i = start - 1; i >= 0; i--) { + if (children[i] && children[i].parentNode === elem) { + baseElem = children[i]; + break; + } + } + elem.insertBefore(frag, baseElem.nextSibling); + } + }; + + var array = data; + if (koArray.isKoArray(data)) { + var subscription = data.subscribe(spliceFunc, null, 'spliceChange'); + ko.utils.domNodeDisposal.addDisposeCallback(elem, function() { + subscription.dispose(); + }); + + array = data.all(); + } else if (!Array.isArray(data)) { + throw new Error("koDom.foreach applied to non-array: " + data); + } + spliceFunc({ array: array, start: 0, added: array.length, deleted: [] }); + }; +} +exports.foreach = foreach; diff --git a/app/client/lib/koDomScrolly.css b/app/client/lib/koDomScrolly.css new file mode 100644 index 00000000..ac300541 --- /dev/null +++ b/app/client/lib/koDomScrolly.css @@ -0,0 +1,3 @@ +.scrolly_outer { + position: relative; /* Forces absolutely-positiong scrolly-div to be within scrolly outer*/ +} diff --git a/app/client/lib/koDomScrolly.js b/app/client/lib/koDomScrolly.js new file mode 100644 index 00000000..8803de27 --- /dev/null +++ b/app/client/lib/koDomScrolly.js @@ -0,0 +1,649 @@ +/** + * Scrolly is a class that allows scrolling a very long list of rows by rendering only those + * that are visible. Note that the elements rendered by scrolly should have box-sizing set to + * border-box. + */ + + + +var _ = require('underscore'); +var ko = require('knockout'); +var assert = require('assert'); +var gutil = require('app/common/gutil'); +var BinaryIndexedTree = require('app/common/BinaryIndexedTree'); +var {Delay} = require('./Delay'); +var dispose = require('./dispose'); +var kd = require('./koDom'); +var dom = require('./dom'); + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +var G = require('./browserGlobals').get('window', '$'); + +/** + * Scrolly may contain multiple panes scrolling in parallel (e.g. for row numbers). The UI for + * each pane consists of two nested pieces: a scrollDiv and a blockDiv. The scrollDiv is very tall + * and mostly empty; the blockDiv contains the actual rendered rows, and is absolutely positioned + * inside its scrollDiv. + */ +function ScrollyPane(scrolly, paneIndex, container, options, itemCreateFunc) { + this.scrolly = scrolly; + this.paneIndex = paneIndex; + this.container = container; + this.itemCreateFunc = itemCreateFunc; + this.preparedRows = []; + + _.extend(this.scrolly.options, options); + + this.container.appendChild( + this.scrollDiv = dom( + 'div.scrolly_outer', + kd.style('height', this.scrolly.totalHeightPx), + this.blockDiv = dom( + 'div', + kd.style('position', 'absolute'), + kd.style('top', this.scrolly.blockTopPx), + kd.style('width', options.fitToWidth ? '100%' : ''), + kd.style('padding-right', options.paddingRight + 'px') + ) + ) + ); + + ko.utils.domNodeDisposal.addDisposeCallback(container, () => { + this.scrolly.destroyPane(this); + // Delete all members, to break cycles. + for (var k in this) { + delete this[k]; + } + }); + + G.$(this.container).on('scroll', () => this.scrolly.onScroll(this) ); +} + +/** + * Prepares the DOM for rows in scrolly's [begin, end) range, reusing currently active rows as + * much as possible. New rows are saved in this.preparedRows, and also added to the end of + * blockDiv so that they may be measured. + */ +ScrollyPane.prototype.prepareNewRows = function() { + var i, item, row, + begin = this.scrolly.begin, + count = this.scrolly.end - begin, + array = this.scrolly.data.peek(), + prevItemModels = this.scrolly.activeItemModels, + prevRows = this.preparedRows; + + if (prevRows.length > 0) { + // Skip this check if there are no rows, maybe we just added this pane. + assert.equal(prevRows.length, prevItemModels.length, + "Rows and models not in sync: " + prevRows.length + "!=" + prevItemModels.length); + } + + this.preparedRows = []; + + // Reuse any reusable old rows. They must be tied to an active model. + for (i = 0; i < prevRows.length; i++) { + row = prevRows[i]; + item = prevItemModels[i]; + if (item._index() === null) { + ko.removeNode(row); + } else { + var relIndex = item._index() - begin; + assert(relIndex >= 0 && relIndex < count, "prepareNewRows saw out-of-range model"); + this.preparedRows[relIndex] = row; + } + } + + // Create any missing rows. + for (i = 0; i < count; i++) { + if (!this.preparedRows[i]) { + item = array[begin + i]; + assert(item, "ScrollyPane item missing at index " + (begin + i)); + item._rowHeightPx(""); // Mark this row as in need of measuring. + row = this.itemCreateFunc(item); + kd.style('height', item._rowHeightPx)(row); + ko.utils.domData.set(row, "itemModel", item); + this.preparedRows[i] = row; + // The row may not end up at the end of blockDiv, but we need to add it to the document in + // order to measure it. We'll move it to the right place in arrangePreparedRows(). + this.blockDiv.appendChild(row); + } + } +}; + +/** + * Returns the measured height of the given prepared row. + */ +ScrollyPane.prototype.measurePreparedRow = function(rowIndex) { + var row = this.preparedRows[rowIndex]; + var rect = row.getBoundingClientRect(); + return rect.bottom - rect.top; +}; + +/** + * Update the DOM with the prepared rows in the correct order. + */ +ScrollyPane.prototype.arrangePreparedRows = function() { + // Note that everything that was in blockDiv previously is now either gone or is in + // preparedRows. So placing all preparedRows into blockDiv automatically removes them from their + // old positions. + // + // For a slight speedup in rendering, we try to avoid removing and reinserting rows + // unnecessarily, as that slows down subsequent rendering. We could try harder, by finding the + // longest common subsequence, but that's quite a bit harder. + for (var i = 0; i < this.preparedRows.length; i++) { + var row = this.preparedRows[i]; + var current = this.blockDiv.childNodes[i]; + if (row !== current) { + this.blockDiv.insertBefore(row, current); + } + } +}; + +//---------------------------------------------------------------------- + +/** + * The Scrolly class is used internally to manage the state of the scrolly. It keeps track of the + * data items being rendered, of the heights of all rows (including cumulative heights, in a + * BinaryIndexedTree), and various other counts and positions. + * + * The actual DOM elements are managed by ScrollyPane class. There may be more than one instance, + * if there are multiple panes scrolling together (e.g. for row numbers). + */ +function Scrolly(dataModel) { + // In the constructor we only initialize the parts shared by all ScrollyPanes. + this.data = dataModel; + this.numRows = 0; + this.options = { + paddingBottom: 0 + }; + + this.panes = []; + + // The items currently rendered. Same as this.data._itemModels, but we manage it manually + // to maintain the invariant that rendered DOM elements match this.activeItemModels. + this.activeItemModels = []; + + // Data structure to store row heights and cumulative offsets of all rows. + this.rowHeights = []; + this.rowOffsetTree = new BinaryIndexedTree(); + // TODO: Reconsider row height for rendering layouts / other tall elements in a scrolly. + this.minRowHeight = 23; // In pixels. Rows will be forced to be at least this tall. + + this.numBuffered = 1; // How many rows to render outside the visible area. + this.numRendered = 1; // Total rows to render. + + this.begin = 0; // Index of the first rendered row + this.end = 0; // Index of the row after the last rendered one + + this.scrollTop = 0; // The scrollTop position of all panes. + this.shownHeight = 0; // The clientHeight of all panes. + this.blockBottom = 0; // Bottom of the rendered block, i.e. rowOffsetTree.getSumTo(this.end) + + // Top in px of the rendered block; rowOffsetTree.getSumTo(this.begin) + this.blockTop = ko.observable(0); + this.blockTopPx = ko.computed(function() { return this.blockTop() + 'px'; }, this); + + // The height of the scrolly_outer div + this.totalHeight = ko.observable(0); + this.totalHeightPx = ko.computed(function() { return this.totalHeight() + 'px'; }, this); + + // Subscribe to data changes, and initialize with the current data. + this.subscription = this.autoDispose( + this.data.subscribe(this.onDataSplice, this, 'spliceChange')); + + // The delayedUpdateSize helper is used by scheduleUpdateSize. + this.delayedUpdateSize = this.autoDispose(Delay.create()); + + // Initialize with the current data. + var array = this.data.all(); + this.onDataSplice({ array: array, start: 0, added: array.length, deleted: [] }); + + //T198: Scrolly should have its own handler to remove, so that when removing handlers it does not + //remove other's handler. + let onResize = () => { + this.scheduleUpdateSize(); + }; + + G.$(G.window).on('resize.scrolly', onResize); + + this.autoDisposeCallback(() => G.$(G.window).off('resize.scrolly', onResize)); + +} +exports.Scrolly = Scrolly; + +dispose.makeDisposable(Scrolly); + + +Scrolly.prototype.debug = function() { + console.log("Scrolly: numRows " + this.numRows + "; panes " + this.panes.length + + "; numRendered " + this.numRendered + " [" + this.begin + ", " + this.end + ")" + + "; block at " + this.blockTop() + " of " + this.totalHeight() + + "; scrolled to " + this.scrollTop + "; shownHeight " + this.shownHeight); + console.assert(this.numRows, this.data.peekLength, + "Wrong numRows; data is " + this.data.peekLength); + console.assert(this.numRows, this.rowHeights.length, + "Wrong rowHeights size " + this.rowHeights.length); + console.assert(this.numRows, this.rowOffsetTree.size(), + "Wrong rowOffsetTree size " + this.rowOffsetTree.size()); + var count = Math.min(this.numRendered, this.numRows); + console.assert(this.end - this.begin, count, + "Wrong range size " + (this.end - this.begin)); + console.assert(this.activeItemModels.length, count, + "Wrong activeItemModels.size " + this.activeItemModels.length); + + var expectedHeight = this.blockBottom - this.blockTop(); + if (count > 0) { + for (var p = 0; p < this.panes.length; p++) { + var topRow = this.panes[p].preparedRows[0].getBoundingClientRect(); + var bottomRow = _.last(this.panes[p].preparedRows).getBoundingClientRect(); + var blockHeight = bottomRow.bottom - topRow.top; + if (blockHeight !== expectedHeight) { + console.warn("Scrolly render pane #%d %dpx bigger from expected (%dpx per row). Ensure items have no margins", + p, blockHeight - expectedHeight, (blockHeight - expectedHeight) / count); + } + } + } +}; + +/** + * Helper that returns the Scrolly object currently associate with the given LazyArrayModel. It + * feels a bit wrong that the model knows about its user, but a LazyArrayModel generally only + * supports a single user (e.g. a single Scrolly), so it makes sense. + */ +function getInstance(dataModel) { + if (!dataModel._scrollyObj) { + dataModel._scrollyObj = Scrolly.create(dataModel); + dataModel._scrollyObj.autoDisposeCallback(() => delete dataModel._scrollyObj); + } + return dataModel._scrollyObj; +} +exports.getInstance = getInstance; + +/** + * Adds a new pane that scrolls as part of this Scrolly object. This call itself does no + * rendering of the pane. + */ +Scrolly.prototype.addPane = function(containerElem, options, itemCreateFunc) { + var pane = new ScrollyPane(this, this.panes.length, containerElem, options, itemCreateFunc); + this.panes.push(pane); + this.scheduleUpdateSize(); +}; + +/** + * Tells Scrolly to call updateSize after things have had a chance to render. + */ +Scrolly.prototype.scheduleUpdateSize = function() { + if (!this.isDisposed() && !this.delayedUpdateSize.isPending()) { + this.delayedUpdateSize.schedule(0, this.updateSize, this); + } +}; + +/** + * Measures the size of the panes and adjusts Scrolly parameters for how many rows to render. + * This should be called as soon as all Scrolly panes have been attached to the Document, and any + * time their outer size changes. + */ +Scrolly.prototype.updateSize = function() { + this.resetHeights(); + this.shownHeight = Math.max(0, Math.max.apply(null, this.panes.map(function(pane) { + return pane.container.clientHeight; + }))); + + // Update counts of rows that are shown. + var numVisible = Math.max(1, Math.ceil(this.shownHeight / this.minRowHeight)); + this.numBuffered = 5; + this.numRendered = numVisible + 2 * this.numBuffered; + + // Re-render everything. + this.begin = gutil.clamp(this.begin, 0, this.numRows - this.numRendered); + this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows); + this.render(); + this.syncScrollPosition(); +}; + +/** + * Called whenever any pane got scrolled. It syncs up all panes to the same scrollTop. + */ +Scrolly.prototype.onScroll = function(pane) { + this.scrollTo(pane.container.scrollTop); +}; + +/** + * Actively scroll all panes to the given scrollTop position, adjusting what is rendered as + * necessary. + */ +Scrolly.prototype.scrollTo = function(top) { + if (top === this.scrollTop) { + return; + } + + this.scrollTop = top; + this.syncScrollPosition(); + + if (this.blockTop() <= top && this.blockBottom >= top + this.shownHeight) { + // Nothing needs to be re-rendered. + //console.log("scrollTo(%s): all elements already shown", top); + return; + } + + // If we are scrolled to the bottom, restore our bottom position at the end. This happens + // in particular when reloading a page scrolled to the bottom. This is in no way general; it's + // just particularly easy to come across. + var atEnd = (top + this.shownHeight >= this.panes[0].container.scrollHeight); + + var rowAtScrollTop = this.rowOffsetTree.getIndex(top); + this.begin = gutil.clamp(rowAtScrollTop - this.numBuffered, 0, this.numRows - this.numRendered); + this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows); + + // Do the magic. + this.render(); + + // If we were scrolled to the bottom, stay that way. + if (atEnd) { + this.scrollTop = this.panes[0].container.scrollHeight - this.shownHeight; + } + + // Sometimes render() affects scrollTop of some panes; restore it to what we want by always + // calling syncScrollPosition() once more after render. + this.syncScrollPosition(); +}; + +/** + * Called when the underlying data array changes. + */ +Scrolly.prototype.onDataSplice = function(splice) { + // We may need to adjust which rows are shown, but render does all the work of figuring out what + // changed and needs re-rendering. + this.numRows = this.data.peekLength; + + // Update rowHeights: reproduce the splice, inserting minRowHeights for the new rows. + this.rowHeights.splice(splice.start, splice.deleted.length); + gutil.arraySplice(this.rowHeights, splice.start, + gutil.arrayRepeat(splice.added, this.minRowHeight)); + + // And rebuild the rowOffsetTree. + this.rowOffsetTree.fillFromValues(this.rowHeights); + this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom); + + // We may be seeing the last row with space below it, so we'll use the same logic as in + // scrollTo, to make sure the rendered range includes all rows we should be seeing. + var rowAtScrollTop = this.rowOffsetTree.getIndex(this.scrollTop); + this.begin = gutil.clamp(rowAtScrollTop - this.numBuffered, 0, this.numRows - this.numRendered); + this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows); + + this.scheduleUpdateSize(); +}; + +/** + * Set all panes to the common scroll position. + */ +Scrolly.prototype.syncScrollPosition = function() { + // Note that setting scrollTop triggers more scroll events, but those get ignored in onScroll + // because top === this.scrollTop. + var top = this.scrollTop; + for (var p = 0; p < this.panes.length; p++) { + // Reading .scrollTop may cause a synchronous reflow, so may be worse than setting it. + this.panes[p].container.scrollTop = top; + } +}; + +/** + * Creates a new item model. There is one for each rendered row. This uses the lazyArray to create + * the model, but adds a _rowHeightPx observable, used for controlling the row height. + */ +Scrolly.prototype.createItemModel = function() { + var item = this.data.makeItemModel(); + item._rowHeightPx = ko.observable(""); + return item; +}; + +/** + * Render rows in [begin, end) range, reusing any currently rendered rows as much as possible. + */ +Scrolly.prototype.render = function() { + //var startTime = Date.now(); + // console.log("Scrolly render (top " + this.scrollTop + "): [" + this.begin + ", " + + // this.end + ") = " + (this.end - this.begin) + " rows"); + + // Invariant: all panes contain DOM elements parallel to this.activeItemModels. + // At the end, this.activeItemModels and DOM in panes represent the range [begin, end). + var i, p, item, index, delta, + count = this.end - this.begin, + array = this.data.peek(), + freeList = []; + + assert(this.end <= array.length, "Scrolly render() exceeds data length of " + array.length); + + // If scrolling up, we may adjust heights of rows, pushing down the row at scrollTop. + // If that happens, we will adjust scrollTop correspondingly. + var rowAtScrollTop = this.rowOffsetTree.getIndex(this.scrollTop); + var sumToScrollTop = this.rowOffsetTree.getSumTo(rowAtScrollTop); + + // Place out-of-range itemModels into a free list. + for (i = 0; i < this.activeItemModels.length; i++) { + item = this.activeItemModels[i]; + index = item._index(); + if (index === null || index < this.begin || index >= this.end) { + freeList.push(item); + } + } + + // Go through the models we need, and fill any missing ones. + for (i = 0, index = this.begin; i < count; i++, index++) { + if (!array[index]) { + // Use the freeList if possible, or create a new model otherwise. + item = freeList.shift() || this.createItemModel(); + this.data.setItemModel(item, index); + // Unset the explicit height so that we can measure what it would naturally be. + item._rowHeightPx(""); + } + } + + // Unset anything else in the free list. + for (i = 0; i < freeList.length; i++) { + this.data.unsetItemModel(freeList[i]); + } + + // Prepare DOM in all panes. This ensures that there is a DOM element for each active item. + // If prepareNewRows creates new DOM, it will unset _rowHeightPx, to mark it for measuring. + for (p = 0; p < this.panes.length; p++) { + this.panes[p].prepareNewRows(); + } + + // Measure the rows, and use the max across panes to update the stored heights. + // Note: this involves a reflow. + for (i = 0, index = this.begin; i < count; i++, index++) { + item = array[index]; + if (item._rowHeightPx.peek() === "") { + var height = this.minRowHeight; + for (p = 0; p < this.panes.length; p++) { + height = Math.max(height, this.panes[p].measurePreparedRow(i)); + } + height = Math.round(height); + + delta = height - this.rowHeights[index]; + if (delta !== 0) { + this.rowHeights[index] = height; + this.rowOffsetTree.addValue(index, delta); + } + } + } + + // Set back the explicit heights of the rows. This is separate from the loop above to make sure + // we don't trigger additional reflows while measuring rows. + for (i = 0, index = this.begin; i < count; i++, index++) { + item = array[index]; + item._rowHeightPx(this.rowHeights[index] + 'px'); + } + + // Render the new rows in the new order in each pane. + for (p = 0; p < this.panes.length; p++) { + this.panes[p].arrangePreparedRows(); + } + + // Save the current activeItemModels. + this.activeItemModels = array.slice(this.begin, this.end); + // console.log("activeItemModels now " + this.activeItemModels.length); + // console.log("rows in panes now are " + this.panes.map( + // function(p) { return p.blockDiv.childNodes.length; }).join(", ")); + + // Update heights and positions of the scrolling pane parts. + this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom); + this.blockTop(this.rowOffsetTree.getSumTo(this.begin)); + this.blockBottom = this.rowOffsetTree.getSumTo(this.end); + + // Adjust scrollTop if previously-shown top moved because of newly-rendered rows above. + delta = this.rowOffsetTree.getSumTo(rowAtScrollTop) - sumToScrollTop; + if (delta !== 0) { + //console.log("Adjusting scroll position by " + delta); + this.scrollTop += delta; + this.syncScrollPosition(); + } + + // this.debug(); + + // Report after timeout, to include the browser rendering time. + //var midTime = Date.now(); + //setTimeout(function() { + // var endTime = Date.now(); + // console.log("Scrolly render took " + (midTime - startTime) + " + " + + // (endTime - midTime) + " = " + (endTime - startTime) + " ms"); + //}, 0); +}; + + +/** + * Re-measure the given array of rows. Re-measures all rows if no array is given. + */ +Scrolly.prototype.resetHeights = function(optRowIndexList) { + var array = this.data.peek(); + if (optRowIndexList) { + for (var i = 0; i < optRowIndexList.length; i++) { + var index = optRowIndexList[i]; + var item = array[index]; + if (item) { + item._rowHeightPx(""); + } + } + } else { + this.activeItemModels.forEach(function(item) { + item._rowHeightPx(""); + }); + } + this.render(); +}; + +/** + * Re-measure the given array of items. + * @param {Array[ItemModel]} items: The affected models (as returned by this.createItemModel). + */ +Scrolly.prototype.resetItemHeights = function(items) { + if (!this.isDisposed()) { + items.forEach(item => item._rowHeightPx("")); + this.render(); + } +}; + +/** + * Scrolls to the position in pixels returned by calcPosition() function. The argument is a + * function because after the initial re-render, some rows may get re-measured and require + * an adjustment to the pixel position. So calcPosition() actually gets called twice. + */ +Scrolly.prototype.scrollToPosition = function(calcPosition) { + var scrollTop = calcPosition(); + this.scrollTo(scrollTop); + + // Repeat in case rows got re-measured during rendering and ended up being below the fold. + // We only may need to scroll a bit further, we should never have to re-render. + scrollTop = calcPosition(); + if (scrollTop !== this.scrollTop) { + this.scrollTop = scrollTop; + this.syncScrollPosition(); + } +}; + +/** + * Scrolls the given row into view. + */ +Scrolly.prototype.scrollRowIntoView = function(rowIndex) { + this.scrollToPosition(() => { + var top = this.rowOffsetTree.getSumTo(rowIndex); + var bottom = top + this.rowHeights[rowIndex]; + // 43 = 23px to adjust for header, + 20px space + return gutil.clamp(this.scrollTop, bottom - this.shownHeight + 43, top - 10); + }); +}; + +/** + * Takes a scroll position object, as stored in the section model, and scrolls to the saved + * position. + * @param {Integer} scrollPos.rowIndex: The index of the row to be scrolled to. + * @param {Integer} scrollPos.offset: The pixel distance of the scroll from the top of the row. + */ +Scrolly.prototype.scrollToSavedPos = function(scrollPos) { + this.scrollToPosition(() => this.rowOffsetTree.getSumTo(scrollPos.rowIndex) + scrollPos.offset); +}; + + +/** + * Returns an object with the index of the first visible row in the view pane, and the + * scroll offset from the top of that row. + * Useful for recording the current state of the scrolly for later re-initialization. + * + * NOTE: There is a compelling case to scroll to the cursor after scrolling to the previous + * scroll position in either the case where rows are added/rearranged/removed, or simply in + * all cases. While this would likely prevent confusion in case changes push the cursor out + * of view, the case that the user scrolled away from the cursor intentionally should also be + * considered. + */ +Scrolly.prototype.getScrollPos = function() { + var rowIndex = this.rowOffsetTree.getIndex(this.scrollTop); + return { + rowIndex: rowIndex, + offset: this.scrollTop - this.rowOffsetTree.getSumTo(rowIndex) + }; +}; + +/** + * Destroys a scrolly pane. + */ +Scrolly.prototype.destroyPane = function(pane) { + // When the last pane is removed, destroy the scrolly. + gutil.arrayRemove(this.panes, pane); + if (this.panes.length === 0) { + this.dispose(); + } +}; + +//---------------------------------------------------------------------- + +/** + * Creates a virtual scrolling interface attached to a LazyArray. Multiple scrolly() calls used + * with the same `data` array will create parallel scrolling panes (e.g. row numbers and data + * scrolling together). + * + * The DOM for items is created using `itemCreateFunc`. As the user scrolls + * around, the item models are assigned to different items, and the DOM is moved around the page, + * to minimize rendering. This is intended to be used with koModel.mappedLazyArray. + * + * @param {LazyModelArray} data A LazyModelArray instance. + * @param {Object} options - Supported options include: + * paddingBottom {number} - Number of pixels to add to bottom of scrolly + * paddingRight {number} - Number of pixels to add to right of scrolly + * fitToWidth {bool} - Whether the scrolly holds a list of layouts + * @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for a number of + * item models (which can get assigned to different items in `data`). Must return a single + * Node (not a DocumentFragment or null). + */ +function scrolly(data, options, itemCreateFunc) { + assert.equal(typeof itemCreateFunc, 'function'); + options = options || {}; + return function(elem) { + var scrollyObj = getInstance(data); + scrollyObj.addPane(elem, options, itemCreateFunc); + ko.utils.domData.set(elem, "scrolly", scrollyObj); + }; +} +exports.scrolly = scrolly; diff --git a/app/client/lib/koForm.css b/app/client/lib/koForm.css new file mode 100644 index 00000000..249316a9 --- /dev/null +++ b/app/client/lib/koForm.css @@ -0,0 +1,724 @@ +.kf_elem { + margin: 0.4rem 5%; +} + +.kf_button_group { + border-radius: 4px; + overflow: hidden; + user-select: none; + border: 1px solid #e0e0e0; +} + +.kf_button_group:hover { + border: 1px solid #d0d0d0; +} + +.kf_button_group:active { + border: 1px solid #d0d0d0; +} + +.kf_button_group.accent { + border: 1px solid #d8955a; +} + +.kf_button_group.accent:hover { + border: 1px solid #c38045; +} + +.kf_button_group.accent:active { + border: 1px solid #c38045; +} + +.kf_button_group.lite { + border: none; +} + +.kf_tooltip { + text-shadow: none; + position: absolute; + z-index: 10; + visibility: hidden; +} + +.kf_tooltip_pointer { + width: 0; + height: 0; + margin: 0 auto; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid rgba(60, 60, 60, .9); +} + +.kf_tooltip_content { + cursor: default; + white-space: nowrap; + min-width: 16px; + min-height: 16px; + padding: 4px; + background-color: rgba(60, 60, 60, .9); + text-align: center; + color: #dadada; + border-radius: 5px; +} + +div:hover > .kf_tooltip { + visibility: visible; +} + +.kf_tooltip_info_text { + border-bottom: 1px solid #888; + margin-bottom: 3px; +} + +.kf_tooltip_info_text > div { + padding-bottom: 4px; +} + +.kf_tooltip_button { + cursor: pointer; + display: inline-block; + font-size: 1.2rem; + margin: 2px 4px; +} + +.kf_tooltip_button:hover { + color: #fff; +} + +.kf_tooltip_button.disabled { + cursor: default; + color: #222; +} + +.kf_prompt { + position: relative; + width: 95%; + margin: 5px auto 10px auto; +} + +.kf_prompt_content { + position: relative; + white-space: nowrap; + width: 100%; + min-width: 16px; + min-height: 16px; + padding: 4px; + background-color: white; + border-radius: 2px; + box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15); + line-height: 1.1rem; + font-size: 1rem; + color: #606060; + z-index: 10; +} + +.kf_prompt_pointer { + position: absolute; + top: -5px; + right: 20px; + width: 10px; + height: 10px; + transform: rotate(45deg); + box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15); + z-index: 8; +} + +.kf_prompt_pointer_overlap { + position: absolute; + top: -5px; + right: 20px; + width: 10px; + height: 10px; + background-color: white; + transform: rotate(45deg); + z-index: 11; +} + +.kf_draggable { + display: inline-block; + cursor: grab; +} + +.kf_draggable.ui-sortable-helper { + cursor: grabbing; +} + +.kf_draggable.disabled { + cursor: default; +} + +.kf_draggable__item { + margin: .2rem .5rem; + padding: .2rem; + background-color: var(--color-list-item); +} + +.kf_draggable__item:hover { + background-color: var(--color-list-item-hover); +} + +.kf_draggable__placeholder--horizontal { + display: inline-block; + height: 1px; +} + +.kf_draggable__placeholder--vertical { + display: block; + width: 1px; +} + +.kf_drag_indicator { + display: inline-block; + color: #777777; +} + +.kf_draggable_content { + display: inline-block; + margin-left: 2px; +} + +.kf_draggable:hover .drag_delete { + display: block; +} + +.drag_delete { + display: none; + float: right; + cursor: pointer; + font-size: 1.0rem; + margin: 2px 2px 0 0; + color: #777777; +} + +.kf_button { + text-align: center; + margin-left: -1px; + border-left: 1px solid #ddd; + padding: 0.5rem 0.5rem; + height: 2.3rem; + line-height: 1.1rem; + font-size: 1rem; + font-weight: bold; + color: #606060; + cursor: default; + user-select: none; + -moz-user-select: none; + background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%); +} + +.kf_button.accent { + background: linear-gradient(to bottom, #f4a74e 0%,#ff9a00 100%); + color: #ffffff; +} +.kf_button.accent:active:not(.disabled) { + background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%); + color: #ffffff; +} +.kf_button.accent.disabled, .kf_button.accent.disabled:active { + color: #A0A0A0; + background: linear-gradient(to top, #fafafa 0%,#f0f0f0 100%); +} + +.kf_button.lite { + height: 1.8rem; + padding: 0.4rem 0.2rem; + border: none; + background: none; +} + +.kf_button.lite:hover:not(.disabled) { + background: #ddd; + color: black; + box-shadow: none; +} + +.kf_check_button.lite.active:not(.disabled) { + background: #ddd; + color: black; + box-shadow: none; +} + +.kf_check_button.lite:active:not(.disabled), +.kf_check_button.lite.active:active:not(.disabled) { + box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2); + background: #ddd; +} + +.kf_button:first-child { + margin-left: 0; + border-left: none; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.kf_button:last-child { + border-right: none; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.kf_button:active:not(.disabled) { + background: linear-gradient(to bottom, #f0f0f0 0%,#fafafa 100%); + box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2); +} +.kf_button.active { + box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2); + background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%); + color: #ffffff; +} +.kf_button.active:active:not(.disabled) { + box-shadow: inset 0px 0px 3px 0px rgba(0,0,0,0.4); + background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%); +} +.kf_button.disabled, .kf_button.disabled:active { + color: #A0A0A0; +} + +.kf_logo_button { + height: 34px; +} + +.kf_btn_logo { + height: 25px; + width: 25px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + margin-right: 5px; +} + +.kf_btn_text { + font-size: 1.1rem; + height: 1.1rem; + margin: 0.7rem; +} + +.kf_check_button.disabled, .kf_check_button.disabled:active { + color: #A0A0A0; + background: linear-gradient(to bottom, #f4f4f4 0%,#e8e8e8 100%); + box-shadow: none; +} + +.kf_checkbox_label { +} + +.kf_checkbox { + width: 1.6rem; + height: 1.6rem; + margin: 0 0 0 0 !important; + vertical-align: middle; + position: relative; +} + +.kf_checkbox:focus { + outline: none !important; +} + +.kf_radio_label { + font-weight: normal; + font-size: 1.1rem; + margin: 0; +} + +.kf_radio { + margin: 0 0.5rem !important; + outline: none !important; + vertical-align: middle; +} + +/** spinner **/ +.kf_spinner { + position: absolute; + box-sizing: content-box; + width: 9px; + height: 17px; + right: 1px; + top: -1px; + + color: #606060; + overflow: hidden; + padding: 1px; +} + +.kf_spinner:hover { + background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%); + border: 1px solid grey; + border-radius: 6px; + padding: 0px; +} + +.kf_spinner_half { + height: 9px; + overflow: hidden; +} + +.kf_spinner_half:active:not(.disabled) { + background: linear-gradient(to bottom, rgba(147,180,242,1) 0%, rgba(135,168,233,1) 10%, rgba(115,149,218,1) 25%, rgba(115,150,224,1) 37%, rgba(115,153,230,1) 50%, rgba(86,134,219,1) 51%, rgba(130,174,235,1) 83%, rgba(151,194,243,1) 100%); +} + +.kf_spinner_arrow { + width: 0px; + height: 0px; + border-left: 3px solid transparent; + border-right: 3px solid transparent; +} +.kf_spinner_arrow.up { + border-top: none; + border-bottom: 5px solid #606060; + margin: 2px auto; +} +.kf_spinner_arrow.down { + border-top: 5px solid #606060; + border-bottom: none; + margin: 1px auto 2px auto; +} + +.kf_collapser { + height: 2.2rem; + font-size: 1.1rem; + white-space: nowrap; + cursor: default; + user-select: none; + -moz-user-select: none; + margin: .5rem; +} + +.kf_triangle_toggle { + display: inline-block; + font-size: .9rem; + width: 1.5rem; + color: #808080; +} + +.kf_triangle_toggle:active { + color: #606060; +} + +.kf_label { + white-space: nowrap; + font-size: 1.1rem; + cursor: default; +} + +.kf_light_label { + font-size: 1.0rem; + white-space: nowrap; +} + +.kf_text { + width: 100%; +} + +.kf_text:focus { + outline: none; + border: 2px solid #ff9a00; + box-shadow: inset 0px 0px 1px 0px rgba(0,0,0,0.2); +} + +.kf_text:disabled { + color: #888; +} + +/** For editableLabel*/ +.kf_editable_label { + min-height: 1.8rem; + white-space: nowrap; + position: relative; + overflow: hidden; +} + +.kf_elabel_text { + overflow: hidden; + text-overflow: ellipsis; +} + +.kf_elabel_input { + border-width: 0px; + text-align: inherit; + top: 0; + left: 0; + padding: 0; + color: #333; +} + +.elabel_content_measure { + position: fixed; + left: 0px; + top: 0px; + padding-top: 2px; + padding-right: 1em; + border: none; + visibility: hidden; + overflow: visible; +} + +/****/ +.kf_num_text { + display: block; + width: 100%; + text-align: right; +} + +.kf_row { + margin: 0.4rem 2.5%; + align-items: center; + -webkit-align-items: center; +} + +.kf_row > .kf_elem { + margin: 0 2.5%; +} + +.kf_elem > .kf_elem { + margin: 0; +} + +.kf_help_row { + margin-top: -0.2rem; + text-align: center; + font-size: 1.1rem; +} + +.kf_help { + font-weight: normal; + font-size: 1.1rem; +} + +.kf_left { + text-align: left; +} + +.kf_right { + text-align: right; +} + +fieldset:disabled { + color: #A0A0A0; +} + +.kf_status_panel { + padding:0.5rem; + box-shadow:0 1px 2px #aaa; + background: white; + margin:0 0.5rem 0.5rem; + border-radius:3px; + overflow:hidden; +} + +.kf_status_indicator { + border-right: 1px black; + font-size: 4rem; + flex-grow: 0; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select:none; +} + +.kf_status_detail { + align-self: center; +} + +.kf_status_indicator.kf_status_success { + color: forestgreen; +} +.kf_status_indicator.kf_status_info { + color: royalblue; +} +.kf_status_indicator.kf_status_warning { + color: orange; +} +.kf_status_indicator.kf_status_error { + color: firebrick; +} + +.kf_scroll_shadow_outer { + height: 0px; + position: relative; +} + +.kf_scroll_shadow { + position: absolute; + bottom: 0; + width: 100%; + height: 9px; + border-bottom: 1px solid #A0A0A0; + box-shadow: 0px 6px 3px -3px #A0A0A0; + z-index: 100; +} + +.kf_scrollable { + overflow-x: hidden; + overflow-y: auto; +} + +/* Based on scrollbox CSS detailed by Roman Komarov - http://kizu.ru/en/fun/shadowscroll/ */ +.scrollbox { + position: relative; + z-index: 1; + overflow: auto; + max-height: 200px; + background: #FFF no-repeat; + background-image: + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,0.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,0.2), rgba(0,0,0,0)); + background-position: 0 0, 0 100%; + background-size: 100% 14px; +} + +.scrollbox:before, +.scrollbox:after { + content: ""; + position: relative; + z-index: -1; + display: block; + height: 30px; + margin: 0 0 -30px; + background: linear-gradient(to bottom,#FFF,#FFF 30%,rgba(255,255,255,0)); +} + +.scrollbox:after { + margin: -30px 0 0; + background: linear-gradient(to bottom,rgba(255,255,255,0),#FFF 70%,#FFF); +} + +.kf_select { + width: 100%; + height: 2.5rem; + border: 1px solid #e0e0e0; + padding: 0.5rem 0.5rem; + line-height: 1.1rem; + font-size: 1rem; + font-weight: bold; + color: #606060; + cursor: default; + border-radius: 4px; + background-image: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%); +} + +.kf_select:hover { + border: 1px solid #d0d0d0; +} + +.kf_select:active { + box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2); + border: 1px solid #d0d0d0; +} + +.kf_select:focus { + outline: none; +} + +.kf_select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #000; +} + +.kf_select:disabled { + color: #A0A0A0; +} + +.kf_select_arrow:after { + content: '\25bc'; + margin-left: -1.4rem; + font-size: .7rem; + pointer-events:none; +} + +.kf_separator { + color: #C8C8C8; + background-color: #C8C8C8; + border: 0; + height: 1px; + width: 100%; + margin: 1rem 0; +} + +/*****************************************/ +/* CSS for midTabs and midTab functions */ +.kf_mid_tabs { + height: 100%; + position: relative; +} + +.kf_mid_tab_labels { + padding: 0 4rem; +} + +.kf_mid_tab_label { + margin-left: -1px; + border-left: 1px solid #e4e4e4; + text-align: center; + padding: 0.5rem 0.5rem; + font-size: 1.3rem; + font-weight: bold; + color: #bfbfbf; + cursor: pointer; + user-select: none; + -moz-user-select: none; + z-index: 1; +} +.kf_mid_tab_label:first-child { + border-left: none; +} + +.kf_mid_tab_label:active, .kf_mid_tab_label.active:active { + color: black; +} + +.kf_mid_tab_label.active { + color: black; + cursor: default; +} + +.kf_mid_tab_content { + padding-top: 1rem; +} + +/*****************************************/ +/* CSS for topTabs and topTab functions. */ +.kf_top_tabs { + height: 100%; +} + +.kf_top_tab_labels { +} + +.kf_top_tab_label { + margin-left: -1px; + border: 1px solid #C8C8C8; + text-align: center; + padding: 0.5rem 0.5rem; + font-weight: bold; + font-size: 1.1rem; + color: #606060; + cursor: default; + user-select: none; + -moz-user-select: none; + border-radius: 5px 5px 0 0; + background: #eee; +} + +.kf_top_tab_label.active { + background: none; + border-bottom: none; + z-index: 10; +} + +.kf_top_tab_label.active:active { + background: linear-gradient(to bottom, rgba(65,141,225,1) 0%,rgba(38,125,200,1) 100%); +} + +.kf_top_tab_container { + height: 100%; + position: relative; +} + +.kf_top_tab_content { + height: 100%; + padding-top: 1rem; + width: 100%; + position: relative; +} diff --git a/app/client/lib/koForm.js b/app/client/lib/koForm.js new file mode 100644 index 00000000..d8922ea1 --- /dev/null +++ b/app/client/lib/koForm.js @@ -0,0 +1,1163 @@ +/** + * koForm provides a number of styled elements (buttons, checkbox, etc) that are tied to + * observables to simplify and standardize the way we construct UI elements (e.g. forms). + * + * TODO: There is some divergence in class names that we use throughout Grist. For example, + * active vs mod-active and disabled vs mod-disabled. We should standardize. + */ + +// Use the browser globals in a way that allows replacing them with mocks in tests. +var G = require('./browserGlobals').get('$', 'window', 'document'); + +const identity = require('lodash/identity'); +const defaults = require('lodash/defaults'); +const debounce = require('lodash/debounce'); +const pick = require('lodash/pick'); +var ko = require('knockout'); +var Promise = require('bluebird'); + +var gutil = require('app/common/gutil'); + +var commands = require('../components/commands'); + +var dom = require('./dom'); +var kd = require('./koDom'); +var koArray = require('./koArray'); + +var modelUtil = require('../models/modelUtil'); + +var setSaveValue = modelUtil.setSaveValue; + + +/** + * Creates a button-looking div inside a buttonGroup; when clicked, clickFunc() will be called. + * The button is not clickable if it contains the class 'disabled'. + */ +exports.button = function(clickFunc, ...moreContentArgs) { + return dom('div.kf_button.flexitem', + dom.on('click', function() { + if (!this.classList.contains('disabled')) { + clickFunc(); + } + }), + moreContentArgs + ); +}; + +/** + * Creates a button with an accented appearance. + * The button is not clickable if it contains the class 'disabled'. + */ +exports.accentButton = function(clickFunc, ...moreContentArgs) { + return this.button(clickFunc, + {'class': 'kf_button flexitem accent'}, + moreContentArgs + ); +}; + +/** + * Creates a button with a minimal appearance for use in prompts. + * The button is not clickable if it contains the class 'disabled'. + */ +exports.liteButton = function(clickFunc, ...moreContentArgs) { + return this.button(clickFunc, + {'class': 'kf_button flexitem lite'}, + moreContentArgs + ); +}; + +/** + * Creates a bigger button with a logo, used for "sign in with google/github/etc" buttons. + * The button is not clickable if it contains the class 'disabled'. + */ +exports.logoButton = function(clickFunc, logoUrl, text, ...moreContentArgs) { + return this.button(clickFunc, + {'class': 'kf_button kf_logo_button flexitem flexhbox'}, + dom('div.kf_btn_logo', { style: `background-image: url(${logoUrl})` }), + dom('div.kf_btn_text', text), + moreContentArgs + ); +}; + +/** + * Creates a button group. Arguments should be `button` and `checkButton` objects. + */ +exports.buttonGroup = function(moreButtonArgs) { + return dom('div.kf_button_group.kf_elem.flexhbox', + dom.fwdArgs(arguments, 0)); +}; + +/** + * Creates a button group with an accented appearance. + * Arguments should be `button` and `checkButton` objects. + */ +exports.accentButtonGroup = function(moreButtonArgs) { + return this.buttonGroup( + [{'class': 'kf_button_group kf_elem flexhbox accent'}].concat(dom.fwdArgs(arguments, 0)) + ); +}; + +/** + * Creates a button group with a minimal appearance. + * Arguments should be `button` and `checkButton` objects. + */ +exports.liteButtonGroup = function(moreButtonArgs) { + return this.buttonGroup( + [{'class': 'kf_button_group kf_elem flexhbox lite'}].concat(dom.fwdArgs(arguments, 0)) + ); +}; + +/** + * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click. + */ +exports.checkButton = function(valueObservable, moreContentArgs) { + return dom('div.kf_button.kf_check_button.flexitem', + kd.toggleClass('active', valueObservable), + dom.on('click', function() { + if (!this.classList.contains('disabled')) { + setSaveValue(valueObservable, !valueObservable()); + } + }), + dom.fwdArgs(arguments, 1)); +}; + +/** + * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click. + * Very similar to `checkButton` but looks flat and does not need to be in a group. + * + * TODO: checkButton and flatCheckButton are identical in function but differ in style and + * class name conventions. We should reconcile them. + */ +exports.flatCheckButton = function(valueObservable, moreContentArgs) { + return dom('div.flexnone', + kd.toggleClass('mod-active', valueObservable), + dom.on('click', function() { + if (!this.classList.contains('mod-disabled')) { + setSaveValue(valueObservable, !valueObservable()); + } + }), + dom.fwdArgs(arguments, 1)); +}; + +/** + * Creates a group of buttons of which only one may be chosen. Arguments should be `optionButton` + * objects. The single `valueObservable` reflects the value of the selected `optionButton`. + */ +exports.buttonSelect = function(valueObservable, moreButtonArgs) { + var groupElem = dom('div.kf_button_group.kf_elem.flexhbox', dom.fwdArgs(arguments, 1)); + + // TODO: Is adding ":not(.disabled)" the best way to avoid execution? + G.$(groupElem).on('click', '.kf_button:not(.disabled)', function() { + setSaveValue(valueObservable, ko.utils.domData.get(this, 'kfOptionValue')); + }); + + kd.makeBinding(valueObservable, function(groupElem, value) { + Array.prototype.forEach.call(groupElem.querySelectorAll('.kf_button'), function(elem, i) { + var v = ko.utils.domData.get(elem, 'kfOptionValue'); + elem.classList.toggle('active', v === value); + }); + })(groupElem); + + return groupElem; +}; + +/** + * Creates a button-like div to use inside a `buttonSelect` group. The `value` will become the + * value of the `buttonSelect` observable when this button is selected. + */ +exports.optionButton = function(value, moreContentArgs) { + return dom('div.kf_button.flexitem', + function(elem) { ko.utils.domData.set(elem, 'kfOptionValue', value); }, + dom.fwdArgs(arguments, 1)); +}; + +/** + * Creates a speech-bubble-like div intended to give more information and options affecting + * its parent when hovered. + */ +exports.toolTip = function(contentArgs) { + return dom('div.kf_tooltip', + dom('div.kf_tooltip_pointer'), + dom('div.kf_tooltip_content', dom.fwdArgs(arguments, 0)), + dom.defer(function(elem) { + var elemWidth = elem.getBoundingClientRect().width; + var parentRect = elem.parentNode.getBoundingClientRect(); + elem.style.left = (-elemWidth/2 + parentRect.width/2) + 'px'; + elem.style.top = parentRect.height + 'px'; + }) + ); +}; + +/** + * Creates a prompt to provide feedback or request more information in the sidepane. + */ +exports.prompt = function(contentArgs) { + return dom('div.kf_prompt', + dom('div.kf_prompt_pointer'), + dom('div.kf_prompt_pointer_overlap'), + dom('div.kf_prompt_content', dom.fwdArgs(arguments, 0)) + ); +}; + +/** + * Checkbox which toggles `valueObservable`. Other arguments become part of the clickable label. + */ +exports.checkbox = function(valueObservable, moreContentArgs) { + return dom('label.kf_checkbox_label.kf_elem', + dom('input.kf_checkbox', {type: 'checkbox'}, + kd.makeBinding(valueObservable, function(elem, value) { + elem.checked = value; + }), + dom.on('change', function() { + setSaveValue(valueObservable, this.checked); + }) + ), + dom.fwdArgs(arguments, 1)); +}; + + +/** + * Radio button for a particular value of the given observable. It is checked when the observable + * matches the value, and selecting it sets the observable to the value. Other arguments become + * part of the clickable label. + */ +exports.radio = function(value, valueObservable, ...domArgs) { + return dom('label.kf_radio_label', + dom('input.kf_radio', {type: 'radio'}, + kd.makeBinding(valueObservable, (elem, val) => { elem.checked = (val === value); }), + dom.on('change', function() { + if (this.checked) { + setSaveValue(valueObservable, value); + } + }) + ), + ...domArgs + ); +}; + +/** + * Create and return DOM for a spinner widget. + * valueObservable: observable for the value, may have save interface. + * This value is not displayed by the created widget. + * getNewValue(value, dir): called with the current value and 1 or -1 direction, + * should return the new value for valueObservable. + * shouldDisable(value, dir): called with current value and 1 or -1 direction, + * should return whether the button in that direction should be enabled. + */ +function genSpinner(valueObservable, getNewValue, shouldDisable) { + let timeout = null; + let origValue = null; + + function startChange(elem, direction) { + stopAutoRepeat(); + G.$(G.window).on('mouseup', onMouseUp); + origValue = valueObservable.peek(); + doChange(direction, true); + } + + function onMouseUp() { + G.$(G.window).off('mouseup', onMouseUp); + stopAutoRepeat(); + setSaveValue(valueObservable, valueObservable.peek(), origValue); + } + function doChange(direction, isFirst) { + const newValue = getNewValue(valueObservable.peek(), direction); + if (newValue !== valueObservable.peek()) { + valueObservable(newValue); + timeout = setTimeout(doChange, isFirst ? 600 : 100, direction, false); + } + } + function stopAutoRepeat() { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + } + + return dom('div.kf_spinner', + dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.up'), + kd.toggleClass('disabled', () => shouldDisable(valueObservable(), 1)), + dom.on('mousedown', () => { startChange(this, 1); }) + ), + dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.down'), + kd.toggleClass('disabled', () => shouldDisable(valueObservable(), -1)), + dom.on('mousedown', () => { startChange(this, -1); }) + ), + dom.on('dblclick', () => false) + ); +} + +/** + * Creates a spinner item linked to `valueObservable`. + * @param {Number} optMin - Optional spinner lower bound + * @param {Number} optMax - Optional spinner upper bound + */ +exports.spinner = function(valueObservable, stepSizeObservable, optMin, optMax) { + var max = optMax !== undefined ? optMax : Infinity; + var min = optMin !== undefined ? optMin : -Infinity; + + function getNewValue(value, direction) { + const step = (ko.unwrap(stepSizeObservable) || 1) * direction; + // Adding step quickly accumulates floating-point errors. We want to keep the value a multiple + // of step, as well as only keep significant decimal digits. The latter is done by converting + // to string and back using 15 digits of precision (max guaranteed to be significant). + value = value || 0; + value = Math.round(value / step) * step + step; + value = parseFloat(value.toPrecision(15)); + return gutil.clamp(value, min, max); + } + function shouldDisable(value, direction) { + return (direction > 0) ? (value >= max) : (value <= min); + } + return genSpinner(valueObservable, getNewValue, shouldDisable); +}; + +/** + * Creates a select spinner item to loop through the `optionObservable` array, + * setting visible value to `valueObservable`. + */ +exports.selectSpinner = function(valueObservable, optionObservable) { + function getNewValue(value, direction) { + const choices = optionObservable.peek(); + const index = choices.indexOf(value); + const newIndex = gutil.mod(index + direction, choices.length); + return choices[newIndex]; + } + function shouldDisable(value, direction) { + return optionObservable().length <= 1; + } + return genSpinner(valueObservable, getNewValue, shouldDisable); +}; + +/** + * Creates an alignment selector linked to `valueObservable`. + */ +exports.alignmentSelector = function(valueObservable) { + return this.buttonSelect(valueObservable, + this.optionButton("left", dom('span.glyphicon.glyphicon-align-left'), + dom.testId('koForm_alignLeft')), + this.optionButton("center", dom('span.glyphicon.glyphicon-align-center'), + dom.testId('koForm_alignCenter')), + this.optionButton("right", dom('span.glyphicon.glyphicon-align-right'), + dom.testId('koForm_alignRight')) + ); +}; + +/** + * Label with a collapser triangle in front, which may be clicked to toggle `isCollapsedObs` + * observable. + */ +exports.collapserLabel = function(isCollapsedObs, moreContentArgs) { + return dom('div.kf_collapser.kf_elem', + dom('span.kf_triangle_toggle', + kd.text(function() { + return isCollapsedObs() ? '\u25BA' : '\u25BC'; + }) + ), + dom.on('click', function() { + isCollapsedObs(!isCollapsedObs.peek()); + }), + dom.fwdArgs(arguments, 1)); +}; + +/** + * Creates a collapsible section. The argument must be a function which takes a boolean observable + * (isCollapsed) as input, and should return an array of elements. The first element is always + * shown, while the rest will be toggled by `isCollapsed` observable. The isMountedCollapsed + * parameter controls the initial state of the collapsible. When true or omitted, the collapsible + * will be closed on load. Otherwise, the collapsible will initialize expanded/uncollapsed. + * + * kf.collapsible(function(isCollapsed) { + * return [ + * kf.collapserLabel(isCollapsed, 'Indents'), + * kf.row(...), + * kf.row(...) + * ]; + * }); + * Returns an array of two items: the always-shown element, and a div containing the rest. + */ +exports.collapsible = function(contentFunc, isMountedCollapsed) { + var isCollapsed = ko.observable(isMountedCollapsed === undefined ? true : isMountedCollapsed); + var content = contentFunc(isCollapsed); + return [ + content[0], + dom('div', + kd.hide(isCollapsed), + dom.fwdArgs(content, 1)) + ]; +}; + + +/** + * Creates a draggable list of rows. The contentArray argument must be an observable array. + * The callbackObj argument should include some or all of the following methods: + * reorder, remove, and receive. + * The reorder callback is executed if an item is dragged and dropped to a new position + * within the same collection or draggable container. The remove and receive callbacks + * are executed together only when an item from one collection is dropped on a different + * collection. The remove callback may be executed alone when users click on the "minus" icon + * for draggable items. The connectAllDraggables function must be called on draggables to + * enable the remove/receive operation between separate draggables. + * + * Each callback must update the respective model tied to the draggable component, + * or the equivalency between the UI and the observable array may be broken. When + * a method is implemented, but the callback cannot update the model for any reason + * (e.g., failure), then this failure should be communicated to the component either + * by throwing an Error in the callback, or by returning a rejected Promise. + * + * + * reorder(item, nextItem) + * @param {Object} item The item being relocated/moved + * @param {Object} nextItem The next item immediately following the new position, + * or null, when the item is moved to the end of the collection. + * remove(item) + * @param {Object} item The item that should be removed from the collection. + * @returns {Object} The item removed from the observable array. This + * value is passed to the receive function as the + * its item parameter. This value must include all the + * necessary data required for connected draggables + * to successfully insert the new value within their + * respective receive functions. + * receive(item, nextItem) + * @param {Object} item The item to insert in the collection. + * @param {Object} nextItem The next item from item's new position. This value + * will be null when item is moved to the end of the list. + * + * @param {Array} contentArray KoArray of model items + * @param {Function} itemCreateFunc Identical to koDom.foreach's itemCreateFunc, this + * function is called as `itemCreateFunc(item)` for each + * array element. Must return a single Node, or null or + * undefined to omit that node. + * @param {Object} options An object containing the reorder, remove, receive + * callback functions, and all other draggable configuration + * options -- + * @param {Boolean} options.removeButton Controls whether the clickable remove/minus icon is + * displayed. If true, this button triggers the remove + * function on click. + * @param {String} options.axis Determines if the list is displayed vertically 'y' or + * horizontally 'x'. + * @param {Boolean|Function} drag_indicator Include the drag indicator. Defaults to true. Accepts + * also a function that returns a dom element. In which + * case, it will be used to create the drag indicator. + * @returns {Node} The DOM Node for the draggable container + */ +exports.draggableList = function(contentArray, itemCreateFunc, options) { + options = options || {}; + defaults(options, { + removeButton: true, + axis: "y", + drag_indicator: true, + itemClass: 'kf_draggable__item' + }); + + var reorderFunc, removeFunc; + itemCreateFunc = itemCreateFunc || identity; + var list = dom('div.kf_drag_container', + function(elem) { + if (options.reorder) { + reorderFunc = Promise.method(options.reorder); + ko.utils.domData.set(elem, 'reorderFunc', reorderFunc); + } + if (options.remove) { + removeFunc = Promise.method(options.remove); + ko.utils.domData.set(elem, 'removeFunc', removeFunc); + } + if (options.receive) { + ko.utils.domData.set(elem, 'receiveFunc', Promise.method(options.receive)); + } + }, + kd.foreach(contentArray, item => { + var row = itemCreateFunc(item); + if (row) { + return dom('div.kf_draggable', + // Fix for JQueryUI bug where mousedown on draggable elements fail to blur + // active element. See: https://bugs.jqueryui.com/ticket/4261 + dom.on('mousedown', () => G.document.activeElement.blur()), + kd.cssClass(options.itemClass), + (options.drag_indicator ? + (typeof options.drag_indicator === 'boolean' ? + dom('span.kf_drag_indicator.glyphicon.glyphicon-option-vertical') : + options.drag_indicator() + ) : null), + kd.style('display', options.axis === 'x' ? 'inline-block' : 'block'), + kd.domData('model', item), + kd.maybe(removeFunc !== undefined && options.removeButton, function() { + return dom('span.drag_delete.glyphicon.glyphicon-remove', + dom.on('click', function() { + removeFunc(item) + .catch(function(err) { + console.warn('Failed to remove item', err); + }); + }) + ); + }), + dom('span.kf_draggable_content.flexauto', row)); + } else { + return null; + } + }) + ); + + G.$(list).sortable({ + axis: options.axis, + tolerance: "pointer", + forcePlaceholderSize: true, + placeholder: 'kf_draggable__placeholder--' + (options.axis === 'x' ? 'horizontal' : 'vertical') + }); + if (reorderFunc === undefined) { + G.$(list).sortable("option", {disabled: true}); + } + + G.$(list).on('sortstart', function(e, ui) { + ko.utils.domData.set(ui.item[0], 'originalParent', ui.item.parent()); + ko.utils.domData.set(ui.item[0], 'originalPrev', ui.item.prev()); + }); + G.$(list).on('sortstop', function(e, ui) { + if (!ko.utils.domData.get(ui.item[0], 'crossedContainers')) { + handleReorderStop.bind(null, list).call(this, e, ui); + } else { + handleConnectedStop.call(list, e, ui); + } + }); + + return list; +}; + +function handleReorderStop(container, e, ui) { + var reorderFunc = ko.utils.domData.get(container, 'reorderFunc'); + var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev'); + if (reorderFunc && !ui.item.prev().is(originalPrev)) { + var movingItem = ko.utils.domData.get(ui.item[0], 'model'); + reorderFunc(movingItem, getNextDraggableItemModel(ui.item)) + .catch(function(err) { + console.warn('Failed to reorder item', err); + G.$(container).sortable('cancel'); + }); + } + resetDraggedItem(ui.item[0]); +} + + +function handleConnectedStop(e, ui) { + var originalParent = ko.utils.domData.get(ui.item[0], 'originalParent'); + var removeOriginal = ko.utils.domData.get(originalParent[0], 'removeFunc'); + var receive = ko.utils.domData.get(ui.item.parent()[0], 'receiveFunc'); + + if (removeOriginal && receive) { + removeOriginal(ko.utils.domData.get(ui.item[0], 'model')) + .then(function(removedItem) { + return receive(removedItem, getNextDraggableItemModel(ui.item)) + .then(function() { + ui.item.remove(); + }) + .catch(revertRemovedItem.bind(null, ui, originalParent, removedItem)); + }) + .catch(function(err) { + console.warn('Error removing item', err); + G.$(originalParent).sortable('cancel'); + }) + .finally(function() { + resetDraggedItem(ui.item[0]); + }); + } else { + console.warn('Missing remove or receive'); + } +} + +function revertRemovedItem(ui, parent, item, err) { + console.warn('Error receiving item. Trying to return removed item.', err); + var originalReceiveFunc = ko.utils.domData.get(parent[0], 'receiveFunc'); + if (originalReceiveFunc) { + var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev'); + var originalNextItem = originalPrev.length > 0 ? + getNextDraggableItemModel(originalPrev) : + getDraggableItemModel(parent.children('.kf_draggable').first()); + originalReceiveFunc(item, originalNextItem) + .catch(function(err) { + console.warn('Failed to receive item in original collection.', err); + }).finally(function() { + ui.item.remove(); + }); + } +} + +function getDraggableItemModel(elem) { + if (elem.length && elem.length > 0) { + return ko.utils.domData.get(elem[0], 'model'); + } + return null; +} + +function getNextDraggableItemModel(elem) { + return elem.next ? getDraggableItemModel(elem.next('.kf_draggable')) : null; +} + +function resetDraggedItem(elem) { + ko.utils.domData.set(elem, 'originalPrev', null); + ko.utils.domData.set(elem, 'originalParent', null); + ko.utils.domData.set(elem, 'crossedContainers', false); +} + +function enableDraggableConnection(draggable) { + G.$(draggable).on('sortremove', function(e, ui) { + ko.utils.domData.set(ui.item[0], 'crossedContainers', true); + ko.utils.domData.set(ui.item[0], 'stopIndex', ui.item.index()); + }); + + if (G.$(draggable).sortable("option", "disabled") && ( + ko.utils.domData.get(draggable, 'receiveFunc') || + ko.utils.domData.get(draggable, 'removeFunc') + )) { + G.$(draggable).sortable( "option", { disabled: false }); + } +} + +function connectDraggableToClass(draggable, className) { + enableDraggableConnection(draggable); + G.$(draggable).addClass(className); + G.$(draggable).sortable("option", {connectWith: "." + className}); +} + +/** + * Connects 2 or more draggableList components together. This connection allows any of the + * draggable components to drag & drop items into and out of any other connected draggable. + * @param {Object} draggableArgs 2 or more draggableList objects + */ +var connectedDraggables = 0; +exports.connectAllDraggables = function(draggableArgs) { + if (draggableArgs.length < 2) { + console.warn('connectAllDraggables requires at least 2 draggable components'); + } + var className = "connected-draggable-" + connectedDraggables++; + for (var i=0; i