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