diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 1669d955..da3ed852 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -29,7 +29,7 @@ jobs:
run: yarn install
- name: Build Node.js test code
- run: yarn run build
+ run: yarn run build http://localhost:9998
- name: Cache Docker images.
uses: ScribeMD/docker-cache@0.3.3
diff --git a/calendar/index.html b/calendar/index.html
new file mode 100644
index 00000000..116d0786
--- /dev/null
+++ b/calendar/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+ Calendar Widget
+
+
+
+
+
+
+
diff --git a/calendar/package.json b/calendar/package.json
new file mode 100644
index 00000000..9875c31e
--- /dev/null
+++ b/calendar/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@gristlabs/widget-calendar",
+ "description": "Widget for visualizing data as an calendar",
+ "homePage": "https://github.com/gristlabs/grist-widget",
+ "version": "0.0.1",
+ "dependencies": {
+ "@toast-ui/calendar": "^2.1.3"
+ },
+ "grist": [
+ {
+ "name": "Calendar",
+ "url": "https://gristlabs.github.io/grist-widget/calendar/index.html",
+ "widgetId": "@gristlabs/widget-calendar",
+ "published": true,
+ "accessLevel": "read table"
+ }
+
+ ]
+}
diff --git a/calendar/page.js b/calendar/page.js
new file mode 100644
index 00000000..37dd8a8c
--- /dev/null
+++ b/calendar/page.js
@@ -0,0 +1,330 @@
+// let's assume that it's imported in an html file
+var grist;
+
+// to keep all calendar related logic;
+let calendarHandler;
+const CALENDAR_NAME = 'standardCalendar';
+
+//for tests
+let dataVersion = Date.now();
+function testGetDataVersion(){
+ return dataVersion;
+}
+
+//registering code to run when a document is ready
+function ready(fn) {
+ if (document.readyState !== 'loading') {
+ fn();
+ } else {
+ document.addEventListener('DOMContentLoaded', fn);
+ }
+}
+
+function isRecordValid(record) {
+ return record.startDate instanceof Date &&
+ record.endDate instanceof Date &&
+ typeof record.title === 'string'
+}
+
+class CalendarHandler {
+ static _mainColor = getComputedStyle(document.documentElement)
+ .getPropertyValue('--main-color');
+
+ static _selectedColor = getComputedStyle(document.documentElement)
+ .getPropertyValue('--selected-color');
+ static getCalendarOptions() {
+ return {
+ week: {
+ taskView: false,
+ },
+ month: {},
+ defaultView: 'week',
+ template: {
+ time(event) {
+ const {title} = event;
+ return `${title}`;
+ },
+ allday(event) {
+ const {title} = event;
+ return `${title}`;
+ },
+ },
+ calendars: [
+ {
+ id: CALENDAR_NAME,
+ name: 'Personal',
+ backgroundColor: CalendarHandler._mainColor,
+ },
+ ],
+ };
+ }
+ constructor() {
+ const container = document.getElementById('calendar');
+ const options = CalendarHandler.getCalendarOptions();
+ this.previousIds = new Set();
+ this.calendar = new tui.Calendar(container, options);
+ this.calendar.on('beforeUpdateEvent', onCalendarEventBeingUpdated);
+ this.calendar.on('clickEvent', async (info) => {
+ await grist.setSelectedRows([info.event.id]);
+ });
+ this.calendar.on('selectDateTime', async (info)=> {
+ await onNewDateBeingSelectedOnCalendar(info);
+ this.calendar.clearGridSelections();
+ });
+ }
+
+ // navigate to the selected date in the calendar and scroll to the time period of the event
+ selectRecord(record) {
+ if (isRecordValid(record)) {
+ if (this._selectedRecordId) {
+ this.calendar.updateEvent(this._selectedRecordId, CALENDAR_NAME, {backgroundColor: CalendarHandler._mainColor});
+ }
+ this.calendar.updateEvent(record.id, CALENDAR_NAME, {backgroundColor: CalendarHandler._selectedColor});
+ this._selectedRecordId = record.id;
+ this.calendar.setDate(record.startDate);
+ const dom = document.querySelector('.toastui-calendar-time');
+ const middleHour = record.startDate.getHours()
+ + (record.endDate.getHours() - record.startDate.getHours()) / 2;
+ dom.scrollTo({top: (dom.clientHeight / 24) * middleHour, behavior: 'smooth'});
+ }
+ }
+
+ // change calendar perspective between week, month and day.
+ changeView(calendarViewPerspective) {
+ this.calendar.changeView(calendarViewPerspective);
+ }
+
+ // navigate to the previous time period
+ calendarPrevious() {
+ this.calendar.prev();
+ }
+
+ // navigate to the next time period
+ calendarNext() {
+ this.calendar.next();
+ }
+
+ //navigate to today
+ calendarToday() {
+ this.calendar.today();
+ }
+
+ // update calendar events based on the collection of records from the grist table.
+ async updateCalendarEvents(calendarEvents) {
+ // we need to keep track the ids of the events that are currently in the calendar to compare it
+ // with the new set of events when they come.
+ const currentIds = new Set();
+ for (const record of calendarEvents) {
+ //chek if event already exist in the calendar - update it if so, create new otherwise
+ const event = this.calendar.getEvent(record.id, CALENDAR_NAME);
+ const eventData = record;
+ if (!event) {
+ this.calendar.createEvents([eventData]);
+ } else {
+ this.calendar.updateEvent(record.id, CALENDAR_NAME, eventData);
+ }
+ currentIds.add(record.id);
+ }
+ // if some events are not in the new set of events, we need to remove them from the calendar
+ if (this.previousIds) {
+ for (const id of this.previousIds) {
+ if (!currentIds.has(id)) {
+ this.calendar.deleteEvent(id, CALENDAR_NAME);
+ }
+ }
+ }
+ this.previousIds = currentIds;
+ }
+}
+
+// when a document is ready, register the calendar and subscribe to grist events
+ready(async () => {
+ calendarHandler = new CalendarHandler();
+ await configureGristSettings();
+});
+
+// Data for column mapping fields in Widget GUI
+function getGristOptions() {
+ return [
+ {
+ name: "startDate",
+ title: "Start Date",
+ optional: false,
+ type: "DateTime",
+ description: "starting point of event",
+ allowMultiple: false
+ },
+ {
+ name: "endDate",
+ title: "End Date",
+ optional: false,
+ type: "DateTime",
+ description: "ending point of event",
+ allowMultiple: false
+ },
+ {
+ name: "title",
+ title: "Title",
+ optional: false,
+ type: "Text",
+ description: "title of event",
+ allowMultiple: false
+ },
+ {
+ name: "isAllDay",
+ title: "Is All Day",
+ optional: true,
+ type: "Bool",
+ description: "is event all day long",
+ }
+ ];
+}
+
+// let's subscribe to all the events that we need
+async function configureGristSettings() {
+ // table selection should change when another event is selected
+ grist.allowSelectBy();
+ // CRUD operations on records in table
+ grist.onRecords(updateCalendar);
+ // When cursor (selected record) change in the table
+ grist.onRecord(gristSelectedRecordChanged);
+ // When options changed in the widget configuration (reaction to perspective change)
+ grist.onOptions(onGristSettingsChanged);
+
+ // bind columns mapping options to the GUI
+ const columnsMappingOptions = getGristOptions();
+ grist.ready({ requiredAccess: 'read table', columns: columnsMappingOptions });
+}
+
+// when a user selects a record in the table, we want to select it on the calendar
+function gristSelectedRecordChanged(record, mappings) {
+ const mappedRecord = grist.mapColumnNames(record, mappings);
+ if (mappedRecord && calendarHandler) {
+ calendarHandler.selectRecord(mappedRecord);
+ }
+}
+
+// when a user changes the perspective in the GUI, we want to save it as grist option
+// - rest of logic is in reaction to the grist option changed
+async function calendarViewChanges(radiobutton) {
+ await grist.setOption('calendarViewPerspective', radiobutton.value);
+}
+
+
+// When a user changes a perspective of calendar, we want this to be persisted in grist options between sessions.
+// this is the place where we can react to this change and update calendar view, or when new session is started
+// (so we are loading previous settings)
+let onGristSettingsChanged = function(options) {
+ let option = options?.calendarViewPerspective ?? 'week';
+ calendarHandler.changeView(option);
+ selectRadioButton(option);
+};
+
+// when user moves or resizes event on the calendar, we want to update the record in the table
+const onCalendarEventBeingUpdated = async (info) => {
+ if (info.changes?.start || info.changes?.end) {
+ let gristEvent = {};
+ gristEvent.id = info.event.id;
+ if(info.changes.start) gristEvent.startDate = roundEpochDateToSeconds(info.changes.start.valueOf());
+ if(info.changes.end) gristEvent.endDate = roundEpochDateToSeconds(info.changes.end.valueOf());
+ await upsertGristRecord(gristEvent);
+ //}
+ }
+};
+
+// saving events to the table or updating existing one - basing on if ID is present or not in the send event
+async function upsertGristRecord(gristEvent){
+ //to update the table, grist requires another format that it is returning by grist in onRecords event (it's flat is
+ // onRecords event and nested ({id:..., fields:{}}) in grist table), so it needs to be converted
+ const mappedRecord = grist.mapColumnNamesBack(gristEvent);
+ // we cannot save record is some unexpected columns are defined in fields, so we need to remove them
+ delete mappedRecord.id;
+ //mapColumnNamesBack is returning undefined for all absent fields, so we need to remove them as well
+ const filteredRecord = Object.fromEntries(Object.entries(mappedRecord)
+ .filter(([key, value]) => value !== undefined));
+ const eventInValidFormat = { id: gristEvent.id, fields: filteredRecord };
+ const table = await grist.getTable();
+ if (gristEvent.id) {
+ await table.update(eventInValidFormat);
+ } else {
+ await table.create(eventInValidFormat);
+ }
+}
+
+// grist expects date in seconds, but the calendar is returning it in milliseconds, so we need to convert it
+function roundEpochDateToSeconds(date) {
+ return date/1000;
+}
+
+// conversion between calendar event object and grist flat format (so the one that is returned in onRecords event
+// and can be mapped by grist.mapColumnNamesBack)
+function buildGristFlatFormatFromEventObject(tuiEvent) {
+ const gristEvent = {
+ startDate: roundEpochDateToSeconds(tuiEvent.start?.valueOf()),
+ endDate: roundEpochDateToSeconds(tuiEvent.end?.valueOf()),
+ isAllDay: tuiEvent.isAllday ? 1 : 0,
+ title: tuiEvent.title??"New Event"
+ }
+ if (tuiEvent.id) { gristEvent.id = tuiEvent.id; }
+ return gristEvent;
+}
+
+// when user selects new date range on the calendar, we want to create a new record in the table
+async function onNewDateBeingSelectedOnCalendar(info) {
+ const gristEvent = buildGristFlatFormatFromEventObject(info);
+ await upsertGristRecord(gristEvent);
+}
+
+//helper function to select radio button in the GUI
+function selectRadioButton(value) {
+ for (const element of document.getElementsByName('calendar-options')) {
+ if (element.value === value) {
+ element.checked = true;
+ }
+ }
+}
+
+// helper function to build a calendar event object from grist flat record
+function buildCalendarEventObject(record) {
+ return {
+ id: record.id,
+ calendarId: CALENDAR_NAME,
+ title: record.title,
+ start: record.startDate,
+ end: record.endDate,
+ isAllday: record.isAllDay,
+ category: 'time',
+ state: 'Free',
+ };
+}
+
+// when some CRUD operation is performed on the table, we want to update the calendar
+async function updateCalendar(records, mappings) {
+ const mappedRecords = grist.mapColumnNames(records, mappings);
+ // if any records were successfully mapped, create or update them in the calendar
+ if (mappedRecords) {
+ const CalendarEventObjects = mappedRecords.filter(isRecordValid).map(buildCalendarEventObject);
+ await calendarHandler.updateCalendarEvents(CalendarEventObjects);
+ }
+ dataVersion = Date.now();
+}
+
+function testGetCalendarEvent(eventId){
+ const calendarObject = calendarHandler.calendar.getEvent(eventId,CALENDAR_NAME);
+ if(calendarObject)
+ {
+ return{
+ title: calendarObject?.title,
+ startDate: calendarObject?.start.toString(),
+ endDate: calendarObject?.end.toString(),
+ isAllDay: calendarObject?.isAllday??false
+ }
+ }else{
+ return calendarObject
+ }
+}
+
+function testGetCalendarViewName(){
+ // noinspection JSUnresolvedReference
+ return calendarHandler.calendar.getViewName();
+}
\ No newline at end of file
diff --git a/calendar/screen.css b/calendar/screen.css
new file mode 100644
index 00000000..20ebf5e0
--- /dev/null
+++ b/calendar/screen.css
@@ -0,0 +1,81 @@
+:root {
+ --main-color: #16B378;
+ --selected-color: #009058;
+}
+
+
+body{
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
+}
+
+#calendar-options{
+ display:flex;
+ gap: 50px;
+ padding-bottom: 10px;
+}
+
+#calendar-navigation-buttons,#calendar-perspective-buttons{
+ display:flex;
+ gap: 0;
+}
+
+#calendar-options input[type="radio"] {
+ opacity: 0;
+ position: fixed;
+ width: 0;
+}
+
+.toastui-calendar-timegrid{
+ height: 100%;
+}
+
+.calendar-button{
+ font-family: inherit;
+ font-size: 1em;
+ border: none;
+ border-top: 1px solid #11B683;
+ border-bottom: 1px solid #11B683;
+ /* border-radius: 4px; */
+ letter-spacing: -0.08px;
+ padding: 4px 8px;
+ background:transparent;
+}
+
+.calendar-button:last-of-type{
+ border-right: 1px solid #11B683;
+ border-radius: 0 5px 5px 0;
+}
+
+.calendar-button:first-of-type{
+ border-left: 1px solid #11B683;
+ border-radius: 5px 0 0 5px;
+}
+
+.calendar-button:hover{
+ color: #009058;
+ border-color: #009058;
+}
+
+input:checked + label{
+ background-color:var(--main-color);
+ color: #ffffff;
+ border-color:#000;
+}
+
+.toastui-calendar-day-view .toastui-calendar-panel:not(.toastui-calendar-time), .toastui-calendar-week-view .toastui-calendar-panel:not(.toastui-calendar-time) {
+ overflow-y: hidden;
+}
+
+#calendar-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ height: 100%;
+}
+
+#calendar{
+ flex: 1;
+ min-height: 600px;
+ flex-shrink: 0;
+ background-color: #ff0000;
+}
\ No newline at end of file
diff --git a/test/calendar.ts b/test/calendar.ts
new file mode 100644
index 00000000..0dd8cb24
--- /dev/null
+++ b/test/calendar.ts
@@ -0,0 +1,163 @@
+import {assert, driver} from 'mocha-webdriver';
+import {getGrist} from "./getGrist";
+
+//not a pretty way to get events from currently used calendar control. but it's working.
+function buildGetCalendarObjectScript(eventId: number) {
+ return `return testGetCalendarEvent(${eventId});`
+}
+
+describe('calendar', function () {
+ this.timeout(20000);
+ const grist = getGrist();
+
+ async function executeAndWaitForCalendar(action: () => Promise) {
+ const oldDataVersion = await getDateVersion();
+ await action();
+ await driver.wait(async ()=> {
+ const dataVersion = await getDateVersion();
+ return dataVersion > oldDataVersion;
+ });
+ }
+
+ //wait until the event is loaded on the calendar
+ async function getCalendarEvent(eventId: number) {
+ let mappedObject:any;
+ mappedObject = await grist.executeScriptOnCustomWidget(buildGetCalendarObjectScript(eventId));
+ return mappedObject;
+ }
+
+ async function getCalendarSettings():Promise {
+ return await grist.executeScriptOnCustomWidget('return testGetCalendarViewName()');
+ }
+
+ async function getDateVersion(): Promise{
+ return await grist.executeScriptOnCustomWidget('return testGetDataVersion()');
+ }
+
+ before(async function () {
+ const docId = await grist.upload('test/fixtures/docs/Calendar.grist');
+ await grist.openDoc(docId);
+ await grist.toggleSidePanel('right', 'open');
+ await grist.addNewSection(/Custom/, /Table1/);
+ await grist.clickWidgetPane();
+ await grist.selectCustomWidget(/Calendar/);
+ await grist.setCustomWidgetAccess('full');
+ await grist.setCustomWidgetMapping('startDate', /From/);
+ await grist.setCustomWidgetMapping('endDate', /To/);
+ await grist.setCustomWidgetMapping('title', /Label/);
+ await grist.setCustomWidgetMapping('isAllDay', /IsFullDay/);
+ });
+
+ it('should create new event when new row is added', async function () {
+ await executeAndWaitForCalendar(async () => {
+ await grist.sendActionsAndWaitForServer([['AddRecord', 'Table1', -1, {
+ From: new Date('2023-08-03 13:00'),
+ To: new Date('2023-08-03 14:00'),
+ Label: "New Event",
+ IsFullDay: false
+ }]]);
+ });
+ const mappedObject = await getCalendarEvent(1);
+ assert.deepEqual(mappedObject, {
+ title: "New Event",
+ startDate: new Date('2023-08-03 13:00').toString(),
+ endDate: new Date('2023-08-03 14:00').toString(),
+ isAllDay: false
+ })
+ });
+
+ it('should create new all day event when new row is added', async function () {
+ await executeAndWaitForCalendar(async () => {
+ await grist.sendActionsAndWaitForServer([['AddRecord', 'Table1', -1, {
+ From: new Date('2023-08-04 13:00'),
+ To: new Date('2023-08-04 14:00'),
+ Label: "All Day Event",
+ IsFullDay: true
+ }]]);
+ });
+ const mappedObject = await getCalendarEvent(2);
+ assert.equal(mappedObject.title, "All Day Event");
+ assert.equal(mappedObject.isAllDay, true);
+ // Ignoring a time component, because it's not important in full day events
+ assert.equal(new Date(mappedObject.startDate).toDateString(),
+ new Date('2023-08-04 00:00:00').toDateString());
+ assert.equal(new Date(mappedObject.endDate).toDateString(),
+ new Date('2023-08-04 00:00:00').toDateString());
+ });
+
+ it('should update event when table data is changed', async function () {
+ await executeAndWaitForCalendar(async () => {
+ await grist.sendActionsAndWaitForServer([['UpdateRecord', 'Table1', 1, {
+ From: new Date('2023-08-03 13:00'),
+ To: new Date('2023-08-03 15:00'),
+ Label: "New Event",
+ IsFullDay: false
+ }]]);
+ });
+ const mappedObject = await getCalendarEvent(1);
+ assert.deepEqual(mappedObject, {
+ title: "New Event",
+ startDate: new Date('2023-08-03 13:00').toString(),
+ endDate: new Date('2023-08-03 15:00').toString(),
+ isAllDay: false
+ })
+ });
+
+ it('should remove event when row is deleted', async function () {
+ await executeAndWaitForCalendar(async () => {
+ await grist.sendActionsAndWaitForServer([['RemoveRecord', 'Table1', 1]]);
+ });
+ const mappedObject = await getCalendarEvent(1)
+ assert.notExists(mappedObject);
+ });
+
+ it('should change calendar perspective when button is pressed', async function () {
+ await grist.inCustomWidget(async () => {
+ await driver.findWait('#calendar-day-label', 200).click();
+ });
+ let viewType = await getCalendarSettings();
+ assert.equal(viewType, 'day');
+ await grist.inCustomWidget(async () => {
+ await driver.findWait('#calendar-month-label', 200).click();
+ });
+ viewType = await getCalendarSettings();
+ assert.equal(viewType, 'month');
+ await grist.inCustomWidget(async () => {
+ await driver.findWait('#calendar-week-label', 200).click();
+ });
+ viewType = await getCalendarSettings();
+ assert.equal(viewType, 'week');
+ })
+
+ it('should navigate to appropriate time periods when button is pressed', async function () {
+ const today = new Date();
+
+ // Function to navigate and validate date change
+ const navigateAndValidate = async (buttonSelector:string, daysToAdd:number) => {
+ await grist.inCustomWidget(async () => {
+ await driver.findWait(buttonSelector, 200).click();
+ });
+
+ const newDate = await grist.executeScriptOnCustomWidget(
+ 'return calendarHandler.calendar.getDate().d.toDate().toDateString()'
+ );
+
+ const expectedDate = new Date(today);
+ expectedDate.setDate(today.getDate() + daysToAdd);
+
+ assert.equal(newDate, expectedDate.toDateString());
+ };
+
+ // Navigate to the previous week
+ await navigateAndValidate('#calendar-button-previous', -7);
+
+ // Navigate to today
+ await navigateAndValidate('#calendar-button-today', 0);
+
+ // Navigate to next week
+ await navigateAndValidate('#calendar-button-next', 7);
+ });
+
+ //TODO: test adding new events and moving existing one on the calendar. ToastUI is not best optimized for drag and drop tests in mocha and i cannot yet make it working correctly.
+
+});
diff --git a/test/fixtures/docs/Calendar.grist b/test/fixtures/docs/Calendar.grist
new file mode 100644
index 00000000..9de2b522
Binary files /dev/null and b/test/fixtures/docs/Calendar.grist differ
diff --git a/test/getGrist.ts b/test/getGrist.ts
index f17df3a4..333d9ef7 100644
--- a/test/getGrist.ts
+++ b/test/getGrist.ts
@@ -1,10 +1,13 @@
-import { ChildProcess, execSync, spawn } from 'child_process';
+import {ChildProcess, execSync, spawn} from 'child_process';
import FormData from 'form-data';
import fs from 'fs';
-import { driver } from 'mocha-webdriver';
+import {driver} from 'mocha-webdriver';
import fetch from 'node-fetch';
-import { GristWebDriverUtils } from 'test/gristWebDriverUtils';
+import {GristWebDriverUtils} from 'test/gristWebDriverUtils';
+
+
+type UserAction = Array;
/**
* Set up mocha hooks for starting and stopping Grist. Return
@@ -14,7 +17,7 @@ export function getGrist(): GristUtils {
const server = new GristTestServer();
const grist = new GristUtils(server);
- before(async function() {
+ before(async function () {
// Server will have started up in a global fixture, we just
// need to make sure it is ready.
// TODO: mocha-webdriver has a way of explicitly connecting a
@@ -75,7 +78,7 @@ export class GristTestServer {
env: {
...process.env,
GRIST_PORT: String(gristPort),
- },
+ }
});
}
@@ -129,7 +132,9 @@ export class GristUtils extends GristWebDriverUtils {
try {
const url = this.url;
const resp = await fetch(url + '/status');
- if (resp.status === 200) { break; }
+ if (resp.status === 200) {
+ break;
+ }
} catch (e) {
// we expect fetch failures initially.
}
@@ -183,6 +188,21 @@ 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()) {
@@ -196,9 +216,9 @@ export class GristUtils extends GristWebDriverUtils {
await this.waitForServer();
}
- public async setCustomWidgetAccess(option: "none"|"read table"|"full") {
+ public async setCustomWidgetAccess(option: "none" | "read table" | "full") {
const text = {
- "none" : "No document access",
+ "none": "No document access",
"read table": "Read selected table",
"full": "Full document access"
};
@@ -206,9 +226,17 @@ export class GristUtils extends GristWebDriverUtils {
await this.driver.findContent(`.test-select-menu li`, text[option]).click();
}
- public async setCustomWidgetMapping(name: string, value: string|RegExp) {
- const click = (selector: string) => driver.findWait(`${selector}`, 2000).click();
- const toggleDrop = (selector: string) => click(`${selector} .test-select-open`);
+ public async setCustomWidgetMapping(name: string, value: string | RegExp) {
+ const click = async (selector: string) => {
+ try {
+ await driver.findWait(selector, 2000).click();
+ } catch (e) {
+ //sometimes here we get into "detached" state and test fail.
+ //if this happened, just try one more time
+ await driver.findWait(selector, 2000).click();
+ }
+ };
+ const toggleDrop = async (selector: string) => await click(`${selector} .test-select-open`);
const pickerDrop = (name: string) => `.test-config-widget-mapping-for-${name}`;
await toggleDrop(pickerDrop(name));
const clickOption = async (text: string | RegExp) => {
@@ -220,12 +248,23 @@ export class GristUtils extends GristWebDriverUtils {
// Crude, assumes a single iframe. Should elaborate.
public async getCustomWidgetBody(selector: string = 'html'): Promise {
- const iframe = driver.find('iframe');
+ const iframe = this.driver.find('iframe');
try {
await this.driver.switchTo().frame(iframe);
- return await driver.find(selector).getText();
+ return await this.driver.find(selector).getText();
} finally {
- await driver.switchTo().defaultContent();
+ await this.driver.switchTo().defaultContent();
+ }
+ }
+
+ public async executeScriptOnCustomWidget(script: string | Function): Promise {
+ const iframe = this.driver.find('iframe');
+ try {
+ await this.driver.switchTo().frame(iframe);
+ const jsValue = await this.driver.executeScript(script);
+ return jsValue as T;
+ } finally {
+ await this.driver.switchTo().defaultContent();
}
}