Skip to content

Commit

Permalink
swiping through images in viewer widget (#117)
Browse files Browse the repository at this point in the history
Viewer widget will show buttons: prev and next at the top, when more than one URL can be found in a cell.
Also, images can be swiped on mobile via gesture.
  • Loading branch information
JakubSerafin committed Feb 21, 2024
1 parent ce8e3ba commit a1fb277
Show file tree
Hide file tree
Showing 11 changed files with 858 additions and 116 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added test/fixtures/docs/Images.grist
Binary file not shown.
Binary file added test/fixtures/images/image1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/images/image2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/images/image3.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 20 additions & 16 deletions test/getGrist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
}

Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
204 changes: 185 additions & 19 deletions test/gristWebDriverUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
}

Expand All @@ -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();
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
Expand All @@ -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;
}
}

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);
}
Loading

0 comments on commit a1fb277

Please sign in to comment.