From dea1a8ba1bcf812d810c0ffc5092e62a7a341892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 13 Apr 2022 19:26:59 +0200 Subject: [PATCH] (core) Implementing search on raw data view Summary: Search now works on Raw Data Page. - Search bar option 'Search on all pages' will change to 'Search on all tables' when on the Raw data page, and will allow searching through all tables. - Little CSS adjustment for an overlay on Raw page (removes z-index as it is not needed, and conflicts with searchbar). - Search bar option ('search on all') gets white background, little padding, and is moved 2 pixels up, this is needed for Raw page. Test Plan: new and updated tests Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3376 --- app/client/components/DataTables.ts | 5 +- app/client/components/RawData.ts | 16 ++- app/client/models/SearchModel.ts | 146 ++++++++++++++++++++++------ app/client/ui/Tools.ts | 21 ++-- app/client/ui2018/search.ts | 8 +- test/nbrowser/gristUtils.ts | 84 +++++++++++++++- 6 files changed, 234 insertions(+), 46 deletions(-) diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index 80a2d6ae..b014ad2b 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -49,7 +49,10 @@ export class DataTables extends Disposable { cssItem( testId('table'), cssItemContent( - cssIcon('TypeTable'), + cssIcon('TypeTable', + // Element to click in tests. + dom.domComputed(use => `table-id-${use(tableRec.tableId)}`) + ), cssLabels( cssTitleLine( cssLine( diff --git a/app/client/components/RawData.ts b/app/client/components/RawData.ts index 1424ca91..15f6eac9 100644 --- a/app/client/components/RawData.ts +++ b/app/client/components/RawData.ts @@ -6,12 +6,13 @@ import {printViewSection} from 'app/client/components/Printing'; import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout'; import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs'; +import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {reportError} from 'app/client/models/errors'; const testId = makeTestId('test-raw-data-'); export class RawData extends Disposable { + private _lightboxVisible: Observable; constructor(private _gristDoc: GristDoc) { super(); const commandGroup = { @@ -19,6 +20,10 @@ export class RawData extends Disposable { printSection: () => { printViewSection(null, this._gristDoc.viewModel.activeSection()).catch(reportError); }, }; this.autoDispose(commands.createGroup(commandGroup, this, true)); + this._lightboxVisible = Computed.create(this, use => { + const section = use(this._gristDoc.viewModel.activeSection); + return Boolean(section.getRowId()); + }); } public buildDom() { @@ -26,8 +31,12 @@ export class RawData extends Disposable { const close = this._close.bind(this); return cssContainer( - dom.create(DataTables, this._gristDoc), - dom.create(DocumentUsage, this._gristDoc.docPageModel), + dom('div', + dom.create(DataTables, this._gristDoc), + dom.create(DocumentUsage, this._gristDoc.docPageModel), + // We are hiding it, because overlay doesn't have a z-index (it conflicts with a searchbar and list buttons) + dom.hide(this._lightboxVisible) + ), /*************** Lightbox section **********/ dom.domComputedOwned(fromKo(this._gristDoc.viewModel.activeSection), (owner, viewSection) => { if (!viewSection.getRowId()) { @@ -80,7 +89,6 @@ const cssContainer = styled('div', ` `); const cssOverlay = styled('div', ` - z-index: 10; background-color: ${colors.backdrop}; inset: 0px; height: 100%; diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index 162035c6..fed30866 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -3,13 +3,14 @@ import {CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; -import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {PageRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {delay} from 'app/common/delay'; -import {waitObs} from 'app/common/gutil'; +import {IDocPage} from 'app/common/gristUrls'; +import {nativeCompare, waitObs} from 'app/common/gutil'; import {TableData} from 'app/common/TableData'; import {BaseFormatter} from 'app/common/ValueFormatter'; -import {Disposable, Observable} from 'grainjs'; +import {Computed, Disposable, Observable} from 'grainjs'; import debounce = require('lodash/debounce'); /** @@ -22,6 +23,7 @@ export interface SearchModel { isEmpty: Observable; // indicates whether the value is empty isRunning: Observable; // indicates that matching is in progress multiPage: Observable; // if true will search across all pages + allLabel: Observable; // label to show instead of default 'Search all pages' findNext(): Promise; // find next match findPrev(): Promise; // find previous match @@ -86,7 +88,63 @@ interface IFinder { } // A callback to opening a page: useful to switch to next page during an ongoing search. -type DocPageOpener = (viewId: number) => Promise; +type DocPageOpener = (viewId: IDocPage) => Promise; + +// To support Raw Data Views we will introduce a 'wrapped' page abstraction. Raw data +// page is not a true page (it doesn't have a record), this will allow as to treat a raw view section +// as if it were a PageRec. +interface ISearchablePageRec { + viewSections(): ViewSectionRec[]; + activeSectionId(): number; + getViewId(): IDocPage; + openPage(): Promise; +} + +class RawSectionWrapper implements ISearchablePageRec { + constructor(private _section: ViewSectionRec) { + + } + public viewSections(): ViewSectionRec[] { + return [this._section]; + } + + public activeSectionId() { + return this._section.id.peek(); + } + + public getViewId(): IDocPage { + return 'data'; + } + + public async openPage() { + this._section.view.peek().activeSectionId(this._section.getRowId()); + await waitObs(this._section.viewInstance); + await this._section.viewInstance.peek()?.getLoadingDonePromise(); + } +} + +class PageRecWrapper implements ISearchablePageRec { + constructor(private _page: PageRec, private _opener: DocPageOpener) { + + } + public viewSections(): ViewSectionRec[] { + return this._page.view.peek().viewSections.peek().peek(); + } + + public activeSectionId() { + return this._page.view.peek().activeSectionId.peek(); + } + + public getViewId() { + return this._page.view.peek().getRowId(); + } + + public openPage() { + return this._opener(this.getViewId()); + } +} + +//activeSectionId /** * An implementation of an IFinder. @@ -96,7 +154,7 @@ class FinderImpl implements IFinder { public startPosition: SearchPosition; private _searchRegexp: RegExp; - private _pageStepper = new Stepper(); + private _pageStepper = new Stepper(); private _sectionStepper = new Stepper(); private _sectionTableData: TableData; private _rowStepper = new Stepper(); @@ -126,16 +184,40 @@ class FinderImpl implements IFinder { } // Initialize the steppers. Returns false if anything goes wrong. - public init(): boolean { - const pages: any[] = this._gristDoc.docModel.visibleDocPages.peek(); - this._pageStepper.array = pages; - this._pageStepper.index = pages.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get()); - if (this._pageStepper.index < 0) { return false; } + public async init(): Promise { + // If we are on a raw view page, pretend that we are looking at true pages. + if ('data' === this._gristDoc.activeViewId.get()) { + // Get all raw sections. + const rawSections = this._gristDoc.docModel.allTables.peek() + // Filter out those we don't have permissions to see (through ACL-tableId will be empty). + .filter(t => Boolean(t.tableId.peek())) + // sort in order that is the same as on the raw data list page, + .sort((a, b) => nativeCompare(a.tableTitle.peek(), b.tableTitle.peek())) + // get rawViewSection, + .map(t => t.rawViewSection.peek()) + // and test if it isn't an empty record. + .filter(s => Boolean(s.id.peek())); + // Pretend that those are pages. + this._pageStepper.array = rawSections.map(r => new RawSectionWrapper(r)); + // Find currently selected one (by comparing to active section id) + this._pageStepper.index = rawSections.findIndex(s => + s.getRowId() === this._gristDoc.viewModel.activeSectionId.peek()); + // If we are at listing, where no section is active open the first page. Otherwise, search will fail. + if (this._pageStepper.index < 0) { + this._pageStepper.index = 0; + await this._pageStepper.value.openPage(); + } + } else { + // Else read all visible pages. + const pages = this._gristDoc.docModel.visibleDocPages.peek(); + this._pageStepper.array = pages.map(p => new PageRecWrapper(p, this._openDocPageCB)); + this._pageStepper.index = pages.findIndex(t => t.viewRef.peek() === this._gristDoc.activeViewId.get()); + if (this._pageStepper.index < 0) { return false; } + } - const view = this._pageStepper.value.view.peek(); - const sections: any[] = view.viewSections().peek(); + const sections = this._pageStepper.value.viewSections(); this._sectionStepper.array = sections; - this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId()); + this._sectionStepper.index = sections.findIndex(s => s.getRowId() === this._pageStepper.value.activeSectionId()); if (this._sectionStepper.index < 0) { return false; } this._initNewSectionShown(); @@ -248,8 +330,8 @@ class FinderImpl implements IFinder { await this._pageStepper.next(step, () => undefined); this._pagesSwitched++; - const view = this._pageStepper.value.view.peek(); - this._sectionStepper.array = view.viewSections().peek(); + const view = this._pageStepper.value; + this._sectionStepper.array = view.viewSections(); } private _initFormatters() { @@ -287,15 +369,16 @@ class FinderImpl implements IFinder { // 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._pageStepper.value.view.peek(); - await this._openDocPage(view.getRowId()); - console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId()); + const view = this._pageStepper.value; + if (this._aborted) { return false; } + await view.openPage(); + console.log("SearchBar: loading view %s section %s", view.getViewId(), 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()); + console.log("SearchBar: loaded view %s section %s", view.getViewId(), section.getRowId()); return true; } return false; @@ -346,11 +429,6 @@ class FinderImpl implements IFinder { this._fieldStepper.index === pos.fieldIndex ); } - - private _openDocPage(viewId: number) { - if (this._aborted) { return; } - return this._openDocPageCB(viewId); - } } /** @@ -363,6 +441,7 @@ export class SearchModelImpl extends Disposable implements SearchModel { public readonly noMatch = Observable.create(this, true); public readonly isEmpty = Observable.create(this, true); public readonly multiPage = Observable.create(this, false); + public readonly allLabel: Computed; private _isRestartNeeded = false; private _finder: IFinder|null = null; @@ -377,12 +456,23 @@ export class SearchModelImpl extends Disposable implements SearchModel { // Set this.noMatch to false when multiPage gets turned ON. this.autoDispose(this.multiPage.addListener(v => { if (v) { this.noMatch.set(false); } })); + this.allLabel = Computed.create(this, use => use(this._gristDoc.activeViewId) === 'data' ? + 'Search all tables' : 'Search all pages'); + // Schedule a search restart when user changes pages (otherwise search would resume from the // previous page that is not shown anymore). Also revert noMatch flag when in single page mode. this.autoDispose(this._gristDoc.activeViewId.addListener(() => { if (!this.multiPage.get()) { this.noMatch.set(false); } this._isRestartNeeded = true; })); + + // On Raw data view, whenever table is closed (so activeSectionId = 0), restart search. + this.autoDispose(this._gristDoc.viewModel.activeSectionId.subscribe((sectionId) => { + if (this._gristDoc.activeViewId.get() === 'data' && sectionId === 0) { + this._isRestartNeeded = true; + this.noMatch.set(false); + } + })); } public async findNext() { @@ -406,17 +496,17 @@ export class SearchModelImpl extends Disposable implements SearchModel { private async _findFirst(value: string) { this._isRestartNeeded = false; this.isEmpty.set(!value); - this._updateFinder(value); + await this._updateFinder(value); if (!value || !this._finder) { this.noMatch.set(true); return; } await this._run(async (finder) => { await finder.matchNext(1); }); } - private _updateFinder(value: string) { + private async _updateFinder(value: string) { if (this._finder) { this._finder.abort(); } const impl = new FinderImpl(this._gristDoc, value, this._openDocPage.bind(this), this.multiPage); - const isValid = impl.init(); + const isValid = await impl.init(); this._finder = isValid ? impl : null; } @@ -438,7 +528,7 @@ export class SearchModelImpl extends Disposable implements SearchModel { } // Opens doc page without triggering a restart. - private async _openDocPage(viewId: number) { + private async _openDocPage(viewId: IDocPage) { await this._gristDoc.openDocPage(viewId); this._isRestartNeeded = false; } diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index eabc5cf0..37ff0772 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -27,6 +27,8 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse gristDoc.docModel.rules.getNumRows() > 0); } owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules)); + // TODO: Create global observable to enable raw tools (TO REMOVE once raw data ui has landed) + (window as any).enableRawTools = Observable.create(null, false); updateCanViewAccessRules(); return cssTools( cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), @@ -47,15 +49,16 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse testId('access-rules'), ), // Raw data - for now hidden. - // cssPageEntry( - // cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'), - // cssPageLink( - // cssPageIcon('Database'), - // cssLinkText('Raw data'), - // testId('raw'), - // urlState().setLinkUrl({docPage: 'data'}) - // ) - // ), + dom.maybe((window as any).enableRawTools, () => + cssPageEntry( + cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'), + cssPageLink( + cssPageIcon('Database'), + cssLinkText('Raw data'), + testId('raw'), + urlState().setLinkUrl({docPage: 'data'}) + ), + )), cssPageEntry( cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'), dom.on('click', () => gristDoc.showTool('docHistory'))) diff --git a/app/client/ui2018/search.ts b/app/client/ui2018/search.ts index a28319c9..4cfc277e 100644 --- a/app/client/ui2018/search.ts +++ b/app/client/ui2018/search.ts @@ -109,8 +109,12 @@ const cssLabel = styled('span', ` const cssOptions = styled('div', ` position: absolute; right: 0; - top: 48px; + top: 46px; z-index: 1; + background: white; + padding: 2px 4px; + overflow: hidden; + white-space: nowrap; `); const cssShortcut = styled('span', ` @@ -202,7 +206,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) { testId('close'), dom.on('click', () => toggleMenu(false))), cssOptions( - labeledSquareCheckbox(model.multiPage, 'Search all pages'), + labeledSquareCheckbox(model.multiPage, dom.text(model.allLabel)), dom.on('mouseenter', () => keepExpanded = true), dom.on('mouseleave', () => keepExpanded = false), testId('option-all-pages'), diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 392f8732..f7d43721 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1045,15 +1045,95 @@ export async function moveToHidden(col: string) { } export async function search(what: string) { - await driver.find('.test-tb-search-icon').doClick(); + await driver.find('.test-tb-search-icon').click(); await driver.sleep(500); - await driver.find('.test-tb-search-input').doClick(); + await driver.find('.test-tb-search-input input').click(); await selectAll(); await driver.sendKeys(what); // Sleep for search debounce time await driver.sleep(120); } +export async function toggleSearchAll() { + await closeTooltip(); + await driver.find('.test-tb-search-option-all-pages').click(); +} + +export async function closeSearch() { + await driver.sendKeys(Key.ESCAPE); + await driver.sleep(500); +} + +export async function closeTooltip() { + await driver.mouseMoveBy({x : 100, y: 100}); + await waitToPass(async () => { + assert.equal(await driver.find('.test-tooltip').isPresent(), false); + }); +} + +export async function searchNext() { + await closeTooltip(); + await driver.find('.test-tb-search-next').click(); +} + +export async function searchPrev() { + await closeTooltip(); + await driver.find('.test-tb-search-prev').click(); +} + +export function getCurrentSectionName() { + return driver.find('.active_section .test-viewsection-title').value(); +} + +export function getCurrentPageName() { + return driver.find('.test-treeview-itemHeader.selected').find('.test-docpage-label').getText(); +} + +export async function getActiveRawTableName() { + const title = await driver.findWait('.test-raw-data-overlay .test-viewsection-title', 100).value(); + return title; +} + +export function getSearchInput() { + return driver.find('.test-tb-search-input'); +} + +export async function hasNoResult() { + await waitToPass(async () => { + assert.match(await driver.find('.test-tb-search-input').getText(), /No results/); + }); +} + +export async function hasSomeResult() { + await waitToPass(async () => { + assert.notMatch(await driver.find('.test-tb-search-input').getText(), /No results/); + }); +} + +export async function searchIsOpened() { + await waitToPass(async () => { + assert.isAbove((await getSearchInput().rect()).width, 50); + }, 500); +} + +export async function searchIsClosed() { + await waitToPass(async () => { + assert.equal((await getSearchInput().rect()).width, 0); + }, 500); +} + +export async function openRawTable(tableId: string) { + await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`).click(); +} + +export async function isRawTableOpened() { + return await driver.find('.test-raw-data-close-button').isPresent(); +} + +export async function closeRawTable() { + await driver.find('.test-raw-data-close-button').click(); +} + /** * Toggles (opens or closes) the filter bar for a section. */