diff --git a/package.json b/package.json index f105d748..26ce2d81 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ }, "devDependencies": { "@types/chai": "^4.3.5", + "@types/lodash.escaperegexp": "^4.1.9", "@types/mocha": "^10.0.1", "@types/node": "18.11.9", "@types/node-fetch": "^2.6.4", "@types/selenium-webdriver": "^4.1.15", "commander": "^11.1.0", "live-server": "^1.2.1", + "lodash": "^4.17.21", + "lodash.escaperegexp": "^4.1.2", "mocha": "^10.2.0", "mocha-webdriver": "0.3.1", "node-fetch": "^2", diff --git a/test/fixtures/docs/Images.grist b/test/fixtures/docs/Images.grist new file mode 100644 index 00000000..c8f8d984 Binary files /dev/null and b/test/fixtures/docs/Images.grist differ diff --git a/test/fixtures/images/image1.jpg b/test/fixtures/images/image1.jpg new file mode 100644 index 00000000..136bb557 Binary files /dev/null and b/test/fixtures/images/image1.jpg differ diff --git a/test/fixtures/images/image2.jpg b/test/fixtures/images/image2.jpg new file mode 100644 index 00000000..0e764a72 Binary files /dev/null and b/test/fixtures/images/image2.jpg differ diff --git a/test/fixtures/images/image3.jpg b/test/fixtures/images/image3.jpg new file mode 100644 index 00000000..4a90653d Binary files /dev/null and b/test/fixtures/images/image3.jpg differ diff --git a/test/getGrist.ts b/test/getGrist.ts index cad3ccaa..18be7d9b 100644 --- a/test/getGrist.ts +++ b/test/getGrist.ts @@ -7,7 +7,7 @@ import fetch from 'node-fetch'; import {GristWebDriverUtils} from 'test/gristWebDriverUtils'; -type UserAction = Array<string | number | object | boolean | null | undefined>; + /** * Set up mocha hooks for starting and stopping Grist. Return @@ -198,26 +198,12 @@ export class GristUtils extends GristWebDriverUtils { await this.waitForServer(); } - public async sendActionsAndWaitForServer(actions: UserAction[], optTimeout: number = 2000) { - const result = await driver.executeAsyncScript(async (actions: any, done: Function) => { - try { - await (window as any).gristDocPageModel.gristDoc.get().docModel.docData.sendActions(actions); - done(null); - } catch (err) { - done(String(err?.message || err)); - } - }, actions); - if (result) { - throw new Error(result as string); - } - await this.waitForServer(optTimeout); - } + public async clickWidgetPane() { const elem = this.driver.find('.test-config-widget-select .test-select-open'); if (await elem.isPresent()) { await elem.click(); - // if not present, may just be already selected. } } @@ -226,6 +212,20 @@ export class GristUtils extends GristWebDriverUtils { await this.waitForServer(); } + public async removeWidget(name: string|RegExp) { + await this.selectSectionByTitle(name); + await this.sendCommand('deleteSection'); + await this.waitForServer(); + } + + public async addCustomSection(name: string, type: string, dataSource: string|RegExp= /Table1/) { + await this.toggleSidePanel('right', 'open'); + await this.addNewSection(/Custom/, dataSource); + await this.clickWidgetPane(); + await this.selectCustomWidget(type); + await this.waitForServer(); + } + public async setCustomWidgetAccess(option: "none" | "read table" | "full") { const text = { "none": "No document access", @@ -275,6 +275,10 @@ export class GristUtils extends GristWebDriverUtils { return this.inCustomWidget(() => this.driver.find(selector).getText()); } + public async getCustomWidgetElementParameter(selector: string, parameter: string): Promise<string> { + return this.inCustomWidget(() => this.driver.find(selector).getAttribute(parameter)); + } + public async executeScriptInCustomWidget<T>(script: Function, ...args: any[]): Promise<T> { return this.inCustomWidget(() => { return driver.executeScript(script, ...args); diff --git a/test/gristWebDriverUtils.ts b/test/gristWebDriverUtils.ts index 77d92ef0..7b2d407a 100644 --- a/test/gristWebDriverUtils.ts +++ b/test/gristWebDriverUtils.ts @@ -8,15 +8,17 @@ * easily. */ -import { WebDriver, WebElement } from 'mocha-webdriver'; +import { Key, WebDriver, WebElement, WebElementPromise } from 'mocha-webdriver'; +import escapeRegExp = require('lodash/escapeRegExp'); -type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'; +type SectionTypes = 'Table' | 'Card' | 'Card List' | 'Chart' | 'Custom'; +type UserAction = Array<string | number | object | boolean | null | undefined>; export class GristWebDriverUtils { public constructor(public driver: WebDriver) { } - public isSidePanelOpen(which: 'right'|'left'): Promise<boolean> { + public isSidePanelOpen(which: 'right' | 'left'): Promise<boolean> { return this.driver.find(`.test-${which}-panel`).matches('[class*=-open]'); } @@ -31,14 +33,44 @@ export class GristWebDriverUtils { public async waitForServer(optTimeout: number = 2000) { await this.driver.wait(() => this.driver.executeScript( "return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())" - + " && window.gristApp.testNumPendingApiRequests() === 0", + + " && window.gristApp.testNumPendingApiRequests() === 0", optTimeout, "Timed out waiting for server requests to complete" )); } + public async sendActionsAndWaitForServer(actions: UserAction[], optTimeout: number = 2000) { + const result = await this.driver.executeAsyncScript(async (actions: any, done: Function) => { + try { + await (window as any).gristDocPageModel.gristDoc.get().docModel.docData.sendActions(actions); + done(null); + } catch (err) { + done(String(err?.message || err)); + } + }, actions); + if (result) { + throw new Error(result as string); + } + await this.waitForServer(optTimeout); + } + + /** + * Runs a Grist command in the browser window. + */ + public async sendCommand(name: string, argument: any = null) { + await this.driver.executeAsyncScript((name: any, argument: any, done: any) => { + const result = (window as any).gristApp.allCommands[name].run(argument); + if (result?.finally) { + result.finally(done); + } else { + done(); + } + }, name, argument); + await this.waitForServer(); + } + - public async login(){ + public async login() { //just click log in to get example account. const menu = await this.driver.findWait('.test-dm-account', 1000); await menu.click(); @@ -50,7 +82,7 @@ export class GristWebDriverUtils { public async waitForSidePanel() { // 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the - // side panes + // side panes const transitionDuration = 0.4; // let's add an extra delay of 0.1 for even more robustness @@ -62,7 +94,7 @@ export class GristWebDriverUtils { * Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional * argument can specify the desired state. */ - public async toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') { + public async toggleSidePanel(which: 'right' | 'left', goal: 'open' | 'close' | 'toggle' = 'toggle') { if ((goal === 'open' && await this.isSidePanelOpen(which)) || (goal === 'close' && !await this.isSidePanelOpen(which))) { return; @@ -80,14 +112,14 @@ export class GristWebDriverUtils { * Gets browser window dimensions. */ public async getWindowDimensions(): Promise<WindowDimensions> { - const {width, height} = await this.driver.manage().window().getRect(); - return {width, height}; + const { width, height } = await this.driver.manage().window().getRect(); + return { width, height }; } // Add a new widget to the current page using the 'Add New' menu. public async addNewSection( - typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions + typeRe: RegExp | SectionTypes, tableRe: RegExp | string, options?: PageWidgetPickerOptions ) { // Click the 'Add widget to page' entry in the 'Add New' menu await this.driver.findWait('.test-dp-add-new', 2000).doClick(); @@ -100,8 +132,8 @@ export class GristWebDriverUtils { // Select type and table that matches respectively typeRe and tableRe and save. The widget picker // must be already opened when calling this function. public async selectWidget( - typeRe: RegExp|string, - tableRe: RegExp|string = '', + typeRe: RegExp | string, + tableRe: RegExp | string = '', options: PageWidgetPickerOptions = {} ) { const driver = this.driver; @@ -229,9 +261,9 @@ export class GristWebDriverUtils { await this.openAccountMenu(); await this.driver.find('.grist-floating-menu .test-dm-account-settings').click(); //close alert if it is shown - if(await this.isAlertShown()){ + if (await this.isAlertShown()) { await this.acceptAlert(); - }; + } await this.driver.findWait('.test-account-page-login-method', 5000); await this.waitForServer(); return new ProfileSettingsPage(this); @@ -300,12 +332,122 @@ export class GristWebDriverUtils { let oldDimensions: WindowDimensions; before(async () => { oldDimensions = await this.driver.manage().window().getRect(); - await this.driver.manage().window().setRect({width: 1920, height: 1080}); + await this.driver.manage().window().setRect({ width: 1920, height: 1080 }); }); after(async () => { await this.driver.manage().window().setRect(oldDimensions); }); } + + public async focusOnCell(columnName: string, row: number) { + const cell = await this.getCell({ col: columnName, rowNum: row }); + await cell.click(); + } + public async fillCell(columnName: string, row: number, value: string) { + await this.focusOnCell(columnName, row); + await this.driver.sendKeys(value) + await this.driver.sendKeys(Key.ENTER); + } + + public async addColumn(table: string, name: string) { + // focus on table + await this.selectSectionByTitle(table); + // add new column using a shortcut + await this.driver.actions().keyDown(Key.ALT).sendKeys('=').keyUp(Key.ALT).perform(); + // wait for rename panel to show up + await this.driver.findWait('.test-column-title-popup', 1000); + // rename and accept + await this.driver.sendKeys(name); + await this.driver.sendKeys(Key.ENTER); + await this.waitForServer(); + } + + /** + * Click into a section without disrupting cursor positions. + */ + public async selectSectionByTitle(title: string|RegExp) { + try { + if (typeof title === 'string') { + title = new RegExp("^" + escapeRegExp(title) + "$", 'i'); + } + // .test-viewsection is a special 1px width element added for tests only. + await this.driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click(); + } catch (e) { + // We might be in mobile view. + await this.driver.findContent(`.test-viewsection-title`, title).findClosest(".view_leaf").click(); + } + } + + + /** + * Returns a visible GridView cell. Options may be given as arguments directly, or as an object. + * - col: column name, or 0-based column index + * - rowNum: 1-based row numbers, as visible in the row headers on the left of the grid. + * - section: optional name of the section to use; will use active section if omitted. + */ + public getCell(col: number | string, rowNum: number, section?: string): WebElementPromise; + public getCell(options: ICellSelect): WebElementPromise; + public getCell(colOrOptions: number | string | ICellSelect, rowNum?: number, section?: string): WebElementPromise { + const mapper = async (el: WebElement) => el; + const options: IColSelect<WebElement> = (typeof colOrOptions === 'object' ? + { col: colOrOptions.col, rowNums: [colOrOptions.rowNum], section: colOrOptions.section, mapper } : + { col: colOrOptions, rowNums: [rowNum!], section, mapper }); + return new WebElementPromise(this.driver, this.getVisibleGridCells(options).then((elems) => elems[0])); + } + + /** + * Returns visible cells of the GridView from a single column and one or more rows. Options may be + * given as arguments directly, or as an object. + * - col: column name, or 0-based column index + * - rowNums: array of 1-based row numbers, as visible in the row headers on the left of the grid. + * - section: optional name of the section to use; will use active section if omitted. + * + * If given by an object, then an array of columns is also supported. In this case, the return + * value is still a single array, listing all values from the first row, then the second, etc. + * + * Returns cell text by default. Mapper may be `identity` to return the cell objects. + */ + public async getVisibleGridCells(col: number | string, rows: number[], section?: string): Promise<string[]>; + public async getVisibleGridCells<T = string>(options: IColSelect<T> | IColsSelect<T>): Promise<T[]>; + public async getVisibleGridCells<T>( + colOrOptions: number | string | IColSelect<T> | IColsSelect<T>, _rowNums?: number[], _section?: string + ): Promise<T[]> { + + if (typeof colOrOptions === 'object' && 'cols' in colOrOptions) { + const { rowNums, section, mapper } = colOrOptions; // tslint:disable-line:no-shadowed-variable + const columns = await Promise.all(colOrOptions.cols.map((oneCol) => + this.getVisibleGridCells({ col: oneCol, rowNums, section, mapper }))); + // This zips column-wise data into a flat row-wise array of values. + return ([] as T[]).concat(...rowNums.map((r, i) => columns.map((c) => c[i]))); + } + + const { col, rowNums, section, mapper = el => el.getText() }: IColSelect<any> = ( + typeof colOrOptions === 'object' ? colOrOptions : + { col: colOrOptions, rowNums: _rowNums!, section: _section } + ); + + if (rowNums.includes(0)) { + // Row-numbers should be what the users sees: 0 is a mistake, so fail with a helpful message. + throw new Error('rowNum must not be 0'); + } + + const sectionElem = section ? await this.getSection(section) : await this.driver.findWait('.active_section', 4000); + const colIndex = (typeof col === 'number' ? col : + await sectionElem.findContent('.column_name', exactMatch(col)).index()); + + const visibleRowNums: number[] = await sectionElem.findAll('.gridview_data_row_num', + async (el) => parseInt(await el.getText(), 10)); + + const selector = `.gridview_data_scroll .record:not(.column_names) .field:nth-child(${colIndex + 1})`; + const fields = mapper ? await sectionElem.findAll(selector, mapper) : await sectionElem.findAll(selector); + return rowNums.map((n) => fields[visibleRowNums.indexOf(n)]); + } + + public getSection(sectionOrTitle: string | WebElement): WebElement | WebElementPromise { + if (typeof sectionOrTitle !== 'string') { return sectionOrTitle; } + return this.driver.findContent(`.test-viewsection-title`, new RegExp("^" + escapeRegExp(sectionOrTitle) + "$", 'i')) + .findClosest('.viewsection_content'); + } } class ProfileSettingsPage { @@ -318,7 +460,7 @@ class ProfileSettingsPage { } public async setLanguage(language: string) { - await this.driver.findWait('.test-account-page-language .test-select-open',100).click(); + await this.driver.findWait('.test-account-page-language .test-select-open', 100).click(); await this.driver.findContentWait('.test-select-menu li', language, 100).click(); await this.gu.waitForServer(); } @@ -332,11 +474,35 @@ export interface WindowDimensions { export interface PageWidgetPickerOptions { tableName?: string; /** Optional pattern of SELECT BY option to pick. */ - selectBy?: RegExp|string; + selectBy?: RegExp | string; /** Optional list of patterns to match Group By columns. */ - summarize?: (RegExp|string)[]; + summarize?: (RegExp | string)[]; /** If true, configure the widget selection without actually adding to the page. */ dontAdd?: boolean; /** If true, dismiss any tooltips that are shown. */ dismissTips?: boolean; -} \ No newline at end of file +} + +export interface IColsSelect<T = WebElement> { + cols: Array<number | string>; + rowNums: number[]; + section?: string | WebElement; + mapper?: (e: WebElement) => Promise<T>; +} + +export interface IColSelect<T = WebElement> { + col: number | string; + rowNums: number[]; + section?: string | WebElement; + mapper?: (e: WebElement) => Promise<T>; +} + +export interface ICellSelect { + col: number | string; + rowNum: number; + section?: string | WebElement; +} + +export function exactMatch(value: string, flags?: string): RegExp { + return new RegExp(`^${escapeRegExp(value)}$`, flags); +} diff --git a/test/viewer.ts b/test/viewer.ts new file mode 100644 index 00000000..2fc19ca7 --- /dev/null +++ b/test/viewer.ts @@ -0,0 +1,353 @@ +import { assert, driver } from 'mocha-webdriver'; +import { getGrist } from "./getGrist"; + +const TEST_IMAGE = 'http://localhost:9998/test/fixtures/images/image1.jpg' +const TEST_IMAGE2 = 'http://localhost:9998/test/fixtures/images/image2.jpg' +const TEST_IMAGE3 = 'http://localhost:9998/test/fixtures/images/image3.jpg' + + +describe('viewer', function () { + this.timeout(30000); + const grist = getGrist(); + before(async function () { + const docId = await grist.upload('test/fixtures/docs/Images.grist'); + await grist.openDoc(docId); + await grist.toggleSidePanel('right', 'open'); + await grist.clickWidgetPane(); + }); + + async function testIfErrorIsDisplayed() { + assert.equal(await grist.inCustomWidget(() => driver.find('#error').isPresent()), true); + } + + describe('row transition', function () { + enum RowData { + NONE = 1, + NONE_NEXT = 2, + SINGLE_IMAGE = 3, + SINGLE_IMAGE_NEXT = 4, + MULTIPLE_IMAGES = 5, + MULTIPLE_IMAGES_NEXT = 6, + + } + before(async function () { + //add set of images to the table + await grist.sendActionsAndWaitForServer( + [ + ['AddRecord', 'Data', RowData.NONE, { Image: "" }], + ['AddRecord', 'Data', RowData.NONE_NEXT, { Image: "" }], + ['AddRecord', 'Data', RowData.SINGLE_IMAGE, { Image: TEST_IMAGE }], + ['AddRecord', 'Data', RowData.SINGLE_IMAGE_NEXT, { Image: TEST_IMAGE }], + ['AddRecord', 'Data', RowData.MULTIPLE_IMAGES, { Image: TEST_IMAGE + " " + TEST_IMAGE2 }], + ['AddRecord', 'Data', RowData.MULTIPLE_IMAGES_NEXT, { Image: TEST_IMAGE + " " + TEST_IMAGE2 }], + ] + ) + }); + it('none to none', async function () { + await selectDataRow(RowData.NONE); + await selectDataRow(RowData.NONE_NEXT); + + await assertImageElementVisible(false); + await assertNavigationButtonsVisible(false); + }); + + it('none to single image', async function () { + await selectDataRow(RowData.NONE); + await selectDataRow(RowData.SINGLE_IMAGE); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(false); + }); + + it('none to multiple images', async function () { + await selectDataRow(RowData.NONE); + await selectDataRow(RowData.MULTIPLE_IMAGES); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(true); + }); + + it('single image to none', async function () { + await selectDataRow(RowData.SINGLE_IMAGE); + await selectDataRow(RowData.NONE); + + await assertImageElementVisible(false); + await assertNavigationButtonsVisible(false); + }); + + it('single image to single image', async function () { + await selectDataRow(RowData.SINGLE_IMAGE); + await selectDataRow(RowData.SINGLE_IMAGE_NEXT); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(false); + }); + + it('single image to multiple images', async function () { + await selectDataRow(RowData.SINGLE_IMAGE); + await selectDataRow(RowData.MULTIPLE_IMAGES); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(true); + }); + + it('multiple images to none', async function () { + await selectDataRow(RowData.MULTIPLE_IMAGES); + await selectDataRow(RowData.NONE); + + await assertImageElementVisible(false); + await assertNavigationButtonsVisible(false); + }); + + it('multiple images to single image', async function () { + await selectDataRow(RowData.MULTIPLE_IMAGES); + await selectDataRow(RowData.SINGLE_IMAGE); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(false); + }); + + it('multiple images to multiple images', async function () { + await selectDataRow(RowData.MULTIPLE_IMAGES); + await selectDataRow(RowData.MULTIPLE_IMAGES_NEXT); + + await assertImageElementVisible(true); + await assertNavigationButtonsVisible(true); + }); + + after(async function () { + //remove row added in before block + await grist.sendActionsAndWaitForServer([ + ['RemoveRecord', 'Data', 6], + ['RemoveRecord', 'Data', 5], + ['RemoveRecord', 'Data', 4], + ['RemoveRecord', 'Data', 3], + ['RemoveRecord', 'Data', 2], + ['RemoveRecord', 'Data', 1], + ], 1000); + }); + }); + + describe('loading images', function () { + it('single column, should load image from that column without mapping', async function () { + await grist.fillCell('Image', 1, TEST_IMAGE); + await assertIfImageIsDisplayed(TEST_IMAGE); + }); + + //in the moment of writing this test, widget has default access only to unmapped columns that was in table at the moment of widget creation + // that means - if there were more that one column in the momnet of creation, error will be displayed. In other case, widget will still use first column + it('multiple columns - column added after widget, no mapping, should work based on first column', async function () { + //add new column + await grist.addColumn('DATA', 'description'); + //because widget was displayed, no error should be shown, and data from first column should be displayed + await assertIfImageIsDisplayed(TEST_IMAGE) + + await grist.undo(2); + + }); + + it('multiple columns - column added before, no mapping, should show error', async function () { + // remove custom widget + await grist.removeWidget(/Image/); + //add new column + await grist.addColumn('DATA', 'description'); + //add widget again + await grist.addCustomSection("Image", 'Image viewer', /Data/); + await grist.setCustomWidgetAccess('full'); + //test if error is displayed + await testIfErrorIsDisplayed() + //undo create column + }); + + + it('multiple columns, with mapping, should load image from mapped column', async function () { + //select image widget + await grist.clickWidgetPane(); + //add mappiing + await grist.setCustomWidgetMapping('ImageUrl', /Image/); + // check if image is showed + await assertIfImageIsDisplayed(TEST_IMAGE); + }); + + after(async function () { + //remove setted cell + await grist.sendActionsAndWaitForServer([ + ['RemoveRecord', 'Data', 1], + ], 1000); + }); + }); + describe('navigation', function () { + before(async function () { + //remove all cells from image table + await grist.sendActionsAndWaitForServer([['RemoveRecord', 'Data', 1]], 1000); + }); + describe('no image', function () { + it('should have navigation buttons hidden', async function () { + await assertNavigationButtonsVisible(false); + }); + it('should have no image', async function () { + await assertImageElementVisible(false); + }); + }) + describe('single image', function () { + before(async function () { + //go to data table + await grist.selectSectionByTitle(/^DATA$/); + await grist.fillCell('Image', 1, TEST_IMAGE); + }); + it('should have navigation buttons hidden', async function () { + await assertNavigationButtonsVisible(false); + }); + it('should have image', async function () { + await assertImageElementVisible(true); + }); + after(async function () { + //remove setted cell + await grist.undo(); + }); + }) + describe('multiple images', function () { + const nextButtonId = '#calendar-button-next'; + const previousButtonId = '#calendar-button-previous'; + before(async function () { + await grist.selectSectionByTitle(/^DATA$/); + //input 3 images in the same cell, separated by space + await grist.fillCell('Image', 1, `${TEST_IMAGE} this is some garbage content ` + + `${TEST_IMAGE2} and event more garbage ` + + `${TEST_IMAGE3}`); + }); + + it('should have navigation buttons visible', async function () { + await assertNavigationButtonsVisible(true); + }); + + it('should have image', async function () { + await assertImageElementVisible(true); + }); + + it('should navigate to next image by button', async function () { + await grist.inCustomWidget(async () => await driver.find(nextButtonId).click()); + await assertIfImageIsDisplayed(TEST_IMAGE2); + await grist.inCustomWidget(async () => await driver.find(nextButtonId).click()); + await assertIfImageIsDisplayed(TEST_IMAGE3); + }); + it('should navigate to previous image by button', async function () { + await grist.inCustomWidget(async () => await driver.find(previousButtonId).click()); + await assertIfImageIsDisplayed(TEST_IMAGE2); + await grist.inCustomWidget(async () => await driver.find(previousButtonId).click()); + await assertIfImageIsDisplayed(TEST_IMAGE); + }); + + it('should navigate to last image by click next button right on last image', async function () { + // verify if first image is displayed + await assertIfImageIsDisplayed(TEST_IMAGE); + // click previous button one time + await grist.inCustomWidget(async () => await driver.find(previousButtonId).click()); + // verify if first image is displayed + await assertIfImageIsDisplayed(TEST_IMAGE3); + }); + + it('should navigate to first image by click previous button right on first image', async function () { + // verify if last image is displayed + await assertIfImageIsDisplayed(TEST_IMAGE3); + // click next button one more time + await grist.inCustomWidget(async () => await driver.find(nextButtonId).click()); + // verify if first image is displayed + await assertIfImageIsDisplayed(TEST_IMAGE); + }); + + it('should navigate to next image by swipe left', async function () { + await swipeLeft('#viewer') + await assertIfImageIsDisplayed(TEST_IMAGE2); + await swipeLeft('#viewer') + await assertIfImageIsDisplayed(TEST_IMAGE3); + }); + + it('should navigate to previous image by swipe right', async function () { + await swipeRight('#viewer') + await assertIfImageIsDisplayed(TEST_IMAGE2); + await swipeRight('#viewer') + await assertIfImageIsDisplayed(TEST_IMAGE); + }); + + after(async function () { + //remove setted cell + await grist.undo(); + }); + }) + }); + + async function swipeLeft(element: string | RegExp) { + grist.executeScriptInCustomWidget(swipeScript, element, 'left') + } + + async function swipeRight(element: string | RegExp) { + grist.executeScriptInCustomWidget(swipeScript, element, 'right') + } + + function swipeScript(elementTag: string, direction: 'left' | 'right') { + let mode = direction; + let element = document.querySelector(elementTag); + + if (!element) { + console.error("Element was not found."); + } + else { + + let touch = new Touch({ + identifier: Date.now(), + target: element, + clientX: 0, + }); + + let touchStart = new TouchEvent('touchstart', { + touches: [touch], + }); + + element.dispatchEvent(touchStart); + + let clientX = (mode === "left") ? -200 : 200; + touch = new Touch({ + identifier: Date.now(), + target: element, + clientX: clientX, + }); + + let touchEnd = new TouchEvent('touchend', { + changedTouches: [touch] + }); + + element.dispatchEvent(touchEnd); + } + } + + async function assertImageElementVisible(expected: boolean) { + const isVisible = await isImageElementVisible(); + assert.equal(isVisible, expected, `Image was expected to be ${expected ? 'visible' : 'not visible'} but it was not.`); + } + + async function assertNavigationButtonsVisible(expected: boolean) { + const isVisible = await areNavigationButtonsVisible(); + assert.equal(isVisible, expected, `Navigation buttons were expected to be ${expected ? 'visible' : 'not visible'} but they were not.`); + } + + async function assertIfImageIsDisplayed(url: string) { + await grist.waitToPass(async () => { + const img = await grist.getCustomWidgetElementParameter('img', 'src'); + assert.equal(img, url); + }); + } + + async function areNavigationButtonsVisible() { + return await grist.inCustomWidget(async () => await driver.find('#navigation-buttons').isDisplayed()); + } + + async function isImageElementVisible() { + return await grist.inCustomWidget(async () => await driver.find('#viewer').isDisplayed()); + } + + async function selectDataRow(row: number) { + await grist.selectSectionByTitle(/^DATA$/); + await grist.focusOnCell('Image', row); + } +}); diff --git a/viewer/index.html b/viewer/index.html index 1c11cfc4..327d9a0d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -1,84 +1,119 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="utf-8"> - <title>A cheap and cheerful image viewer</title> - <script src="https://docs.getgrist.com/grist-plugin-api.js"></script> - <style> - html, body, textarea { - width: 100%; - height: 100vh; - padding: 0; - margin: 0; - } - #error { - display: none; - background: red; - color: white; - padding: 20px; - text-align: center; - } - #viewer { - display: none; - text-align: center; - } - img { - width: 100%; - height: 100%; - object-fit: contain; - } - </style> - </head> - <body> - <div id="error"></div> - <img id="viewer" /> - <script> - var viewer = document.getElementById('viewer'); - function showError(msg) { - var el = document.getElementById('error') - if (!msg) { - el.style.display = 'none'; - } else { - el.innerHTML = msg; - el.style.display = 'block'; - } - } - grist.ready({ - columns: [{ name: "ImageUrl", title: 'Image URL', type: 'Text'}], - requiredAccess: 'read table' - }); - // Helper function that reads first value from a table with a single column. - function singleColumn(record) { - const columns = Object.keys(record || {}).filter(k => k !== 'id'); - return columns.length === 1 ? record[columns[0]] : undefined; - } - grist.onNewRecord(() => { - showError(""); - viewer.style.display = 'none'; - }); - grist.onRecord(function(record) { - // If user picked all columns, this helper function will return a mapped record. - const mapped = grist.mapColumnNames(record); - // We will fallback to reading a value from a single column to - // support old way of mapping (exposing only a single column). - // New widgets should only check if mapped object is truthy. - const data = mapped ? mapped.ImageUrl : singleColumn(record); - delete record.id; - var keys = Object.keys(record); - if (data === undefined) { - showError("Please choose a column to show in the Creator Panel."); - } else { - showError(""); - if (!data) { - viewer.style.display = 'none'; - } else { - var parts = data.split(' '); - var url = parts[parts.length - 1]; - viewer.src = url; - viewer.style.display = 'block'; - } - } - }); - </script> - </body> + +<head> + <meta charset="utf-8"> + <title>A cheap and cheerful image viewer</title> + <script src="https://docs.getgrist.com/grist-plugin-api.js"></script> + <style> + .icon-arrow-left { + mask-image: var(--icon-ArrowLeft); + -webkit-mask-image: var(--icon-ArrowLeft); + } + + .icon-arrow-right { + mask-image: var(--icon-ArrowRight); + -webkit-mask-image: var(--icon-ArrowRight); + } + + + .icon { + position: relative; + display: inline-block; + vertical-align: middle; + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-position: center; + mask-size: contain; + -webkit-mask-size: contain; + width: 100%; + height: 0; + padding-bottom: 100%; + background-color: var(--icon-color, var(--grist-theme-text, black)); + } + + :root { + --icon-ArrowLeft: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA4Ljc4NDkzNDYsMTQuNjcwNzUzIDIuNzg0NjI5Niw4LjYyMTMyMjUgYyAtMC4zNjgyMDg0LC0wLjM3MTIzIC0wLjM2ODIwODQsLTAuOTczMSAwLC0xLjM0NDMyIGwgNi4wMDAzMDUsLTYuMDQ5NDI0IGMgMC4zNjgyMSwtMC4zNzEyMjI3NiAwLjk2NTE5LC0wLjM3MTIyMjc2IDEuMzMzNDAwNCwwIDAuMzY4MjEsMC4zNzEyMjMgMC4zNjgyMSwwLjk3MzA5NCAwLDEuMzQ0MzE0IGwgLTQuMzkwNzUwNCw0LjQyNjY5IGggNy43ODA4OTA0IHYgMS45MDExNSBIIDUuNzI3NTg0NiBsIDQuMzkwNzUwNCw0LjQyNjcyMDUgYyAwLjM2ODIxLDAuMzcxMiAwLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAtMC4zNjgyMTA0LDAuMzcxMiAtMC45NjUxOTA0LDAuMzcxMiAtMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); + --icon-ArrowRight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA3LjIzMjAxMzcsMTQuNjcwNzUzIDEzLjIzMjMxOSw4LjYyMTMyMjUgYyAwLjM2ODIwOCwtMC4zNzEyMyAwLjM2ODIwOCwtMC45NzMxIDAsLTEuMzQ0MzIgTCA3LjIzMjAxMzcsMS4yMjc1Nzg1IGMgLTAuMzY4MjEsLTAuMzcxMjIyNzYgLTAuOTY1MTksLTAuMzcxMjIyNzYgLTEuMzMzNDAwNCwwIC0wLjM2ODIxLDAuMzcxMjIzIC0wLjM2ODIxLDAuOTczMDk0IDAsMS4zNDQzMTQgbCA0LjM5MDc1MDcsNC40MjY2OSBIIDIuNTA4NDczMyB2IDEuOTAxMTUgSCAxMC4yODkzNjQgTCA1Ljg5ODYxMzMsMTMuMzI2NDUzIGMgLTAuMzY4MjEsMC4zNzEyIC0wLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAwLjM2ODIxMDQsMC4zNzEyIDAuOTY1MTkwNCwwLjM3MTIgMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); + --icon-Tick: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); + } + + html, + body, + textarea { + width: 100%; + height: 100vh; + padding: 0; + margin: 0; + } + + #error { + display: none; + background: red; + color: white; + padding: 20px; + text-align: center; + } + + #viewer { + display: none; + text-align: center; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + #navigation-buttons { + display: flex; + justify-content: space-between; + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + + .btn { + pointer-events: auto; + cursor: pointer; + background-color: transparent; + border: none; + height: 100%; + width: 50px; + filter: drop-shadow(0 4px 4px var(--grist-theme-page-bg, black)); + } + + .btn:hover>.icon { + transform: scale(1.2); + /* Adjust this value to change the size of the icon on hover */ + } + + body { + display: flex; + flex-direction: column; + overflow: hidden; + } + </style> + <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"> +</head> + +<body> + <div id="navigation-buttons" style="display: none"> + <button id="calendar-button-previous" class="btn" onclick="imageRotator.previousImage()"> + <span class="icon icon-arrow-left"></span> + </button> + <button id="calendar-button-next" class="btn" onclick="imageRotator.nextImage()"> + <span class="icon icon-arrow-right"></span> + </button> + </div> + <div id="error"></div> + <img id="viewer" alt="image loaded by gris from table" src="" /> + <script src="script.js"></script> +</body> + </html> + \ No newline at end of file diff --git a/viewer/script.js b/viewer/script.js new file mode 100644 index 00000000..300f9838 --- /dev/null +++ b/viewer/script.js @@ -0,0 +1,159 @@ +class ImageRotator { + _urls = []; + _urlIndex = 0; + + _getElement() { + if (this.element === undefined || this.element === null || this.element === '') { + this.viewer = document.getElementById(this.elementCssTag); + } + return this.viewer; + } + + previousImage() { + if (this._urlIndex > 0) { + this._urlIndex--; + } else { + this._urlIndex = this._urls.length - 1; + } + this.showImage(); + } + + nextImage() { + if (this._urlIndex < this._urls.length - 1) { + this._urlIndex++; + } else { + this._urlIndex = 0; + } + this.showImage(); + } + + setImages(urls) { + this._urls = urls; + this._urlIndex = 0; + this.showImage(); + } + + showImage() { + const url = this._urls[this._urlIndex]; + const viewer = this._getElement(); + if (!url) { + viewer.style.display = 'none'; + } else { + viewer.src = url; + viewer.alt = `URL: ${url}`; // When url does not point to an image, the url itself is shown as alt text. + viewer.style.display = 'block'; + } + } + + constructor(element) { + this.elementCssTag = element; + } +} + +class SwipeHandler { + _startX; + _endX; + //this sets the minimum swipe distance, to avoid noise and to filter actual swipes from just moving fingers + _treshold = 100; + onSwipeLeft; + onSwipeRight; + + //Function to handle swipes + _handleTouch() { + //calculate the distance on x-axis and o y-axis. Check whether had the great moving ratio. + const xDist = this._endX - this._startX; + if (Math.abs(xDist) > this._treshold) { + if (xDist > 0) { + this.onSwipeRight(); + } else { + this.onSwipeLeft(); + } + } + } + + constructor() { + const viewer = document.getElementById('viewer'); + viewer.addEventListener('touchstart', (event) => { + this._startX = event.touches[0].clientX; + }); + viewer.addEventListener('touchend', (event) => { + this._endX = event.changedTouches[0].clientX; + this._handleTouch(); + }); + } +} + +let imageRotator; +let swipeHandler; +imageRotator = new ImageRotator('viewer'); +swipeHandler = new SwipeHandler(); +swipeHandler.onSwipeLeft = () => imageRotator.nextImage(); +swipeHandler.onSwipeRight = () => imageRotator.previousImage(); + +window.onload = function () { + if ('ontouchstart' in window) { + var btns = document.getElementsByClassName('btn'); + for (var i = 0; i < btns.length; i++) { + btns[i].style.display = 'none'; + } + } +}; + +function showError(msg) { + let el = document.getElementById('error'); + if (!msg) { + el.style.display = 'none'; + } else { + el.innerHTML = msg; + el.style.display = 'block'; + } +} + +function toggleNavigationButtons(show = false) { + const buttons = document.getElementById('navigation-buttons'); + if (show) { + buttons.style.display = 'flex'; + } else { + buttons.style.display = 'none'; + } +} + +grist.ready({ + columns: [{ name: "ImageUrl", title: 'Image URL', type: 'Text' }], + requiredAccess: 'read table', +}); + +// Helper function that reads first value from a table with a single column. +function singleColumn(record) { + const columns = Object.keys(record || {}).filter(k => k !== 'id'); + return columns.length === 1 ? record[columns[0]] : undefined; +} + +grist.onNewRecord(() => { + showError(""); + viewer.style.display = 'none'; +}); +grist.onRecord(function (record) { + // If user picked all columns, this helper function will return a mapped record. + const mapped = grist.mapColumnNames(record); + // We will fallback to reading a value from a single column to + // support old way of mapping (exposing only a single column). + // New widgets should only check if mapped object is truthy. + const data = mapped ? mapped.ImageUrl : singleColumn(record); + delete record.id; + let showNavigation = false; + if (data === undefined) { + showError("Please choose a column to show in the Creator Panel."); + } else { + showError(""); + if (!data) { + viewer.style.display = 'none'; + } else { + const imageUrlRegex = /(https?:\/\/[^\s]+)/g; + urls = data.match(imageUrlRegex) || []; + imageRotator.setImages(urls); + showNavigation = urls.length > 1; + } + toggleNavigationButtons(showNavigation); + } +}); diff --git a/yarn.lock b/yarn.lock index ed7d8ec9..3c9e4684 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,6 +64,18 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.5.tgz#ae69bcbb1bebb68c4ac0b11e9d8ed04526b3562b" integrity sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng== +"@types/lodash.escaperegexp@^4.1.9": + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/lodash.escaperegexp/-/lodash.escaperegexp-4.1.9.tgz#279b1efc46b54b54ad31702b32b5512671e01a9a" + integrity sha512-CMQc8v16YzhnPZnMhYQuJUksI8BmL1jy/lHJkiXi4/t24Lx7Qvy7qxew1abrDJGa1T2i0NzLLaR27NK1/qp5Ow== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.202" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" + integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== + "@types/mocha@^10.0.1": version "10.0.1" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b" @@ -1471,6 +1483,16 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.escaperegexp@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" + integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + log-symbols@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"