diff --git a/app/client/components/ColumnFilters.css b/app/client/components/ColumnFilters.css index 8f97d51d2c..44f08c738d 100644 --- a/app/client/components/ColumnFilters.css +++ b/app/client/components/ColumnFilters.css @@ -66,15 +66,6 @@ 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; diff --git a/app/client/components/DocConfigTab.js b/app/client/components/DocConfigTab.js deleted file mode 100644 index 62a5b6cfca..0000000000 --- a/app/client/components/DocConfigTab.js +++ /dev/null @@ -1,34 +0,0 @@ -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/GristDoc.ts b/app/client/components/GristDoc.ts index 9eff9dbcd9..1261ef43f9 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -11,7 +11,6 @@ import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel'; import * as commands from 'app/client/components/commands'; import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor"; import {DocComm} from 'app/client/components/DocComm'; -import * as DocConfigTab from 'app/client/components/DocConfigTab'; import {Drafts} from "app/client/components/Drafts"; import {EditorMonitor} from "app/client/components/EditorMonitor"; import {buildDefaultFormLayout} from 'app/client/components/Forms/FormView'; @@ -537,8 +536,6 @@ export class GristDoc extends DisposableWithEvents { this._handleTriggerQueueOverflowMessage(); - this.autoDispose(DocConfigTab.create({gristDoc: this})); - this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool))); this.comparison = options.comparison || null; diff --git a/app/client/components/RecordLayoutEditor.js b/app/client/components/RecordLayoutEditor.js index 6b1339aafb..ff2a8203b4 100644 --- a/app/client/components/RecordLayoutEditor.js +++ b/app/client/components/RecordLayoutEditor.js @@ -125,7 +125,7 @@ RecordLayoutEditor.prototype.buildFinishButtons = function() { RecordLayoutEditor.prototype.buildLeafDom = function() { return dom('div.layout_grabbable.g_record_layout_editing', - dom('div.g_record_delete_field.glyphicon.glyphicon-eye-close', + cssIconEyeClose( dom.on('mousedown', (ev) => ev.stopPropagation()), dom.on('click', (ev, elem) => { ev.preventDefault(); @@ -151,4 +151,18 @@ const cssCollapseIcon = styled(icon, ` margin: -3px -2px -2px 2px; `); +const cssIconEyeClose = styled('div.g_record_delete_field', ` + &::before { + display: block; + background-color: var(--grist-color-dark-text); + content: ' '; + mask-image: var(--icon-EyeHide); + width: 14px; + height: 14px; + mask-size: contain; + mask-repeat: no-repeat; + } +` +); + module.exports = RecordLayoutEditor; diff --git a/app/client/components/ValidationPanel.css b/app/client/components/ValidationPanel.css deleted file mode 100644 index 7f9a1061c2..0000000000 --- a/app/client/components/ValidationPanel.css +++ /dev/null @@ -1,29 +0,0 @@ -.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 deleted file mode 100644 index c7b1dbee5a..0000000000 --- a/app/client/components/ValidationPanel.js +++ /dev/null @@ -1,100 +0,0 @@ -/* 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'); -var {makeT} = require('app/client/lib/localization'); - -const t = makeT('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 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: t("Rule {{length}}", {length: 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: t("Update formula (Shift+Enter)")}, - kd.toggleClass('disabled', editorUpToDate) - ) - ) - ) - ); - }) - ) - ]; -}; - -module.exports = ValidationPanel; diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 0fa0fe36a7..6cc5ffab04 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -2,7 +2,6 @@ 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/GridView"; declare module "app/client/lib/Mousetrap"; declare module "app/client/lib/browserGlobals"; diff --git a/app/client/lib/koForm.css b/app/client/lib/koForm.css index 968d07ac14..a27910f2b3 100644 --- a/app/client/lib/koForm.css +++ b/app/client/lib/koForm.css @@ -185,6 +185,24 @@ div:hover > .kf_tooltip { color: #777777; } +.kf_draggable__icon::before { + display: block; + background-color: var(--grist-theme-control-secondary-fg, var(--grist-color-slate)); + content: ' '; + width: 14px; + height: 14px; + mask-size: contain; + mask-repeat: no-repeat; +} + +.kf_draggable__icon.icon-dragdrop::before { + mask-image: var(--icon-DragDrop); +} + +.kf_draggable__icon.icon-remove::before { + mask-image: var(--icon-Remove); +} + .kf_draggable_content { display: inline-block; margin-left: 2px; diff --git a/app/client/lib/koForm.js b/app/client/lib/koForm.js index eb6ab0600d..37fa805f4e 100644 --- a/app/client/lib/koForm.js +++ b/app/client/lib/koForm.js @@ -329,20 +329,6 @@ exports.selectSpinner = function(valueObservable, optionObservable) { 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. @@ -479,12 +465,12 @@ exports.draggableList = function(contentArray, itemCreateFunc, options) { kd.cssClass(options.itemClass), (options.drag_indicator ? (typeof options.drag_indicator === 'boolean' ? - dom('span.kf_drag_indicator.glyphicon.glyphicon-option-vertical') : + dom('span.kf_drag_indicator.kf_draggable__icon.icon-dragdrop') : options.drag_indicator() ) : null), kd.domData('model', item), kd.maybe(removeFunc !== undefined && options.removeButton, function() { - return dom('span.drag_delete.glyphicon.glyphicon-remove', + return dom('span.drag_delete.kf_draggable__icon.icon-remove', dom.on('click', function() { removeFunc(item) .catch(function(err) { diff --git a/app/client/lib/multiselect.css b/app/client/lib/multiselect.css deleted file mode 100644 index d3f2198b63..0000000000 --- a/app/client/lib/multiselect.css +++ /dev/null @@ -1,37 +0,0 @@ -.multiselect-list-item { - margin: 5px; - padding: 5px; - font-size: 1.2rem; - color: black; - background-color: #555; -} - -.multiselect-remove { - float: right; - font-size: 1.0rem; - visibility: hidden; - cursor: pointer; -} - -.multiselect-label { - display: inline-block; -} - -.multiselect-list-item:hover > .multiselect-remove { - visibility: visible; -} - -.multiselect-input { - width: 95%; - margin: 0 2.5%; -} - -.multiselect-hint { - font-size: 1.1rem; - margin: 0 2.5%; - color: var(--color-hint-text); -} - -.multiselect-selected:not(.multiselect-empty) { - margin-bottom: 6px; -} diff --git a/app/client/lib/multiselect.js b/app/client/lib/multiselect.js deleted file mode 100644 index 71c951b8a1..0000000000 --- a/app/client/lib/multiselect.js +++ /dev/null @@ -1,89 +0,0 @@ -/* global $ */ -var ko = require('knockout'); -var kf = require('./koForm'); -var dom = require('./dom'); -var kd = require('./koDom'); - -/** - * Creates a multi-select implemented with a draggable list of selected items followed by - * an autocomplete input containing the remaining selectable items. - * - * Items in `selected` list can be arbitrary objects, and get passed to remove()/reorder(). - * Items for auto-complete should have 'value' and 'label' properties, and are passed to add(). - * - * @param {Function} source(request, response): - * Called with the autocomplete request, containing .term with the search term entered so far. - * The response callback must be called with a list of suggested items (with 'value' and - * 'label' properties). The selected item is passed to add(). The caller should filter out - * items already selected if appropriate. - * @param {koArray} selected: - * KoArray of selected items. - * @param {Function} itemCreateFunc: - * Called as `itemCreateFunc(item)` for each element of the `selected` array. Should return a - * single Node, or null or undefined to omit that node. - * @param {Function} options.add(autoCompleteItem): - * Called to add a new item. - * @param {Function} options.remove(item): - * Called to remove a selected item. - * @param {Function} options.reorder(item, nextItem): - * Optional. Called to move item to just before nextItem (or to the end when nextItem is null). - * If omitted, items are not draggable. The callback must update the 'selected' array to - * match the UI. See koForm.draggableList for more details. - * @param {String} options.hint: - * Optional. Text to display above the input if nothing is selected. - */ -function multiselect(source, selected, itemCreateFunc, options) { - options = options || {}; - var noneSelected = ko.computed(() => selected.all().length === 0); - var selector; - var input; - - // Calls add on the item, closes the autocomplete and clears the input. - function selectItem(item) { - options.add(item); - $(input).autocomplete("close"); - input.value = ''; - } - - // Searches for the item by label in the source and selects the first match. - function searchItem(searchTerm) { - source({ term: searchTerm }, resp => { - var item = resp.find(respItem => respItem.label === searchTerm); - if (item) { selectItem(item); } - }); - } - - // Main selector dom with draggable list. - selector = dom('div.multiselect', - dom.autoDispose(noneSelected), - dom('div.multiselect-selected', - kf.draggableList(selected, item => itemCreateFunc(item), { - drag_indicator: Boolean(options.reorder), - removeButton: true, - reorder: options.reorder, - remove: options.remove - }), - kd.toggleClass('multiselect-empty', noneSelected), - kd.maybe(noneSelected, () => dom('div.multiselect-hint', options.hint || "")) - ), - input = dom('input.multiselect-input', - dom.on('focus', () => { $(input).autocomplete("search"); }), - dom.on('change', () => { searchItem(input.value); }) - ) - ); - - // Set up the auto-complete widget. - $(input).autocomplete({ - source: source, - minLength: 0, - delay: 10, - focus: () => false, // Keeps input empty on focus - select: function(event, ui) { - selectItem(ui.item); - return false; - } - }); - - return selector; -} -module.exports = multiselect; diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 056f03e6b0..4333672aa6 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -89,12 +89,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse cssPageLink(cssPageIcon('Log'), cssLinkText(t("Document History")), testId('log'), dom.on('click', () => gristDoc.showTool('docHistory'))) ), - // TODO: polish validation and add it back - dom.maybe((use) => use(gristDoc.app.features).validationsTool, () => - cssPageEntry( - cssPageLink(cssPageIcon('Validation'), cssLinkText(t("Validate Data")), testId('validate'), - dom.on('click', () => gristDoc.showTool('validations')))) - ), cssPageEntry( cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'code'), cssPageLink(cssPageIcon('Code'), diff --git a/app/client/widgets/AttachmentsWidget.ts b/app/client/widgets/AttachmentsWidget.ts index c360baf15d..0d750dd999 100644 --- a/app/client/widgets/AttachmentsWidget.ts +++ b/app/client/widgets/AttachmentsWidget.ts @@ -190,7 +190,7 @@ const cssAttachmentWidget = styled('div', ` } `); -const cssAttachmentIcon = styled('div.glyphicon.glyphicon-paperclip', ` +const cssAttachmentIcon = styled('div', ` position: absolute; top: 2px; left: 5px; @@ -213,6 +213,17 @@ const cssAttachmentIcon = styled('div.glyphicon.glyphicon-paperclip', ` .${cssAttachmentWidget.className}:hover &-hover { display: inline; } + + &::before { + display: block; + background-color: var(--grist-control-bg, --grist-theme-text, black); + content: ' '; + mask-image: var(--icon-FieldAttachment); + width: 14px; + height: 14px; + mask-size: contain; + mask-repeat: no-repeat; + } `); const cssAttachmentPreview = styled('div', ` diff --git a/app/common/UserConfig.ts b/app/common/UserConfig.ts index fbf4c5fe90..3fe79b68a1 100644 --- a/app/common/UserConfig.ts +++ b/app/common/UserConfig.ts @@ -22,8 +22,7 @@ export interface ISupportedFeatures { // otherwise they could be spoofed. formulaBar?: boolean; - // Plugin views, REPL, and Validations all need work, but are exposed here to allow existing + // Plugin views, and REPL all need work, but are exposed here to allow existing // tests to continue running. These only affect client-side code. customViewPlugin?: boolean; - validationsTool?: boolean; } diff --git a/test/nbrowser/Validations.ntest.js b/test/nbrowser/Validations.ntest.js deleted file mode 100644 index a2c0fb77f8..0000000000 --- a/test/nbrowser/Validations.ntest.js +++ /dev/null @@ -1,101 +0,0 @@ -import { removePrefix } from 'app/common/gutil'; -import { assert, driver } from 'mocha-webdriver'; -import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; - -describe('Validations.ntest', function() { - const cleanup = test.setupTestSuite(this); - - before(async function() { - await gu.supportOldTimeyTestCode(); - await gu.useFixtureDoc(cleanup, "Hello.grist", true); - await driver.executeScript(`window.gristApp.enableFeature('validationsTool', true)`); - }); - - afterEach(function() { - return gu.checkForErrors(); - }); - - it("can add empty validation, which should not show indicators", async function() { - // Open the validations config pane (lives in the Document side pane). - await gu.openSidePane('validate'); - await $('$Validation_addRule').wait(assert.isDisplayed); - - // There should be no validation lines to begin with. - assert.lengthOf(await $("$Validation_rules > .kf_row").array(), 0); - - // Create one, and wait for something to appear. - await $("$Validation_addRule").click(); - await $("$Validation_rules > .validation").wait(); - - // Now there should be one validation rule. - assert.lengthOf(await $("$Validation_rules > .validation").array(), 1); - - // Make sure there are no "validation failure" badgets. - assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 0); - - // Change rule to fail always, and ensure there is a validation failure for each cell. - var formula = $("$Validation_rules .validation_formula").eq(0).find(".ace_editor").wait().elem(); - await formula.click(); - await gu.sendKeys('False'); - await $("$Validation_rules .kf_button:contains(Apply)").click(); - await gu.waitForServer(); - assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 4); - - // Empty out the rule, and see that it now passes. - await driver.withActions(a => a.doubleClick(formula)); - await gu.sendKeys($.DELETE); - await $("$Validation_rules .kf_button:contains(Apply)").click(); - await gu.waitForServer(); - assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 0); - }); - - /** - * Helper to fetch information about a validation failure badge. Returns an object with .text - * being the badge's text (count of failures), and .title being the title attribute. - */ - async function getRowValidation(rowIndex) { - try { - const elem = $(".gridview_data_row_num").eq(rowIndex).find(".validation_error_number"); - const text = await elem.getText(); - const title = await elem.getAttribute('title'); - return { text: text, title: removePrefix(title, "Validation failed: ") }; - } catch (e) { - if (/NoSuchElement/.test(String(e))) { return null; } - throw e; - } - } - - it("should show correct failure counts and messages", async function() { - // Enter some data into first column. - await gu.enterGridValues(0, 1, [["foo", "BAR", "17", ""]]); - await gu.waitForServer(); - - // Change rule to something non-trivial. - await $("$Validation_rules .validation_formula").eq(0).find(".ace_editor").click(); - await gu.sendKeys("$B.lower() == $B"); - await $(".validation").eq(0).findOldTimey(".kf_button:contains(Apply)").click(); // 2nd row should fail. - - // Add a rule that raises an exception. - await $("$Validation_addRule").click(); - await gu.waitForServer(); - await $("$Validation_rules .validation_formula").eq(1).find(".ace_editor").click(); - await gu.sendKeys("int($B) > 0"); - await $(".validation").eq(1).findOldTimey(".kf_button:contains(Apply)").click(); // Rows 1,2,3 should fail. - await gu.waitForServer(); - - // Assert correct number of badges, and correct numbers in them. - await gu.waitForServer(2000); - assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 3); - assert.deepEqual(await getRowValidation(0), { text: "1", title: "Rule 2" }); - assert.deepEqual(await getRowValidation(1), { text: "2", title: "Rule 1, Rule 2" }); - assert.deepEqual(await getRowValidation(2), null); - assert.deepEqual(await getRowValidation(3), { text: "1", title: "Rule 2" }); - - // Now change some data and ensure badges and titles changed appropriately. - await gu.enterGridValues(0, 1, [["FOO", "100", "-17"]]); - assert.deepEqual(await getRowValidation(0), { text: "2", title: "Rule 1, Rule 2" }); - assert.deepEqual(await getRowValidation(1), null); - assert.deepEqual(await getRowValidation(2), { text: "1", title: "Rule 2" }); - assert.deepEqual(await getRowValidation(3), { text: "1", title: "Rule 2" }); - }); -});