Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calendar widget #56

Merged
merged 67 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
41f6981
first version
JakubSerafin Jun 21, 2023
f739e37
updating events from calendar to table
JakubSerafin Jun 22, 2023
86bd58c
mapping columns
JakubSerafin Jun 22, 2023
9ee8531
changing calendar perspective - day/month/week
JakubSerafin Jun 22, 2023
3766351
typos fixes, allDayEvent support improved
JakubSerafin Jun 22, 2023
c215ffa
calendar changes
JakubSerafin Jun 23, 2023
c41bc49
styling, navigating to the row selected in main widget
JakubSerafin Jun 23, 2023
c02a8b2
scroll
JakubSerafin Jun 23, 2023
d6fdaa5
deleting events, improved styles, creating new event based on selection
JakubSerafin Jul 13, 2023
1a3b9b8
calendar widget being selectable as linking source.
JakubSerafin Jul 17, 2023
6b2a30c
saving edited event is now independent of columns name (based on mapp…
JakubSerafin Jul 17, 2023
9ab1af5
creeating new events can handle any column names now
JakubSerafin Jul 17, 2023
ad5065c
first version
JakubSerafin Jun 21, 2023
06fa897
updating events from calendar to table
JakubSerafin Jun 22, 2023
ce02e71
mapping columns
JakubSerafin Jun 22, 2023
8dd89c1
changing calendar perspective - day/month/week
JakubSerafin Jun 22, 2023
df8d4f7
typos fixes, allDayEvent support improved
JakubSerafin Jun 22, 2023
4ea9d9a
calendar changes
JakubSerafin Jun 23, 2023
06ff8e6
styling, navigating to the row selected in main widget
JakubSerafin Jun 23, 2023
86a9a70
scroll
JakubSerafin Jun 23, 2023
b85bedd
deleting events, improved styles, creating new event based on selection
JakubSerafin Jul 13, 2023
5fd4729
calendar widget being selectable as linking source.
JakubSerafin Jul 17, 2023
84d0b49
saving edited event is now independent of columns name (based on mapp…
JakubSerafin Jul 17, 2023
a2151b1
creeating new events can handle any column names now
JakubSerafin Jul 17, 2023
ae46dfc
Merge remote-tracking branch 'origin/POC/calendar-widget-poc' into PO…
JakubSerafin Aug 1, 2023
dfbe568
unit tests for calendar
JakubSerafin Aug 3, 2023
bf57d15
tests
JakubSerafin Aug 7, 2023
4b62148
calendar refactor
JakubSerafin Aug 7, 2023
f2ebcf5
refactor - selecting event to mark
JakubSerafin Aug 7, 2023
0d10535
move part of the code to the class to give it some structure
JakubSerafin Aug 7, 2023
a8ce80f
refactoring editing and creating events form calendar
JakubSerafin Aug 8, 2023
9effe1b
refactor: creating calendar events moved into the class
JakubSerafin Aug 8, 2023
c7c8f2b
refactor: remove global Calendar variable
JakubSerafin Aug 8, 2023
9efdf7a
comments
JakubSerafin Aug 8, 2023
ddbdb8a
test fix after refactor
JakubSerafin Aug 8, 2023
2514dfe
Merge remote-tracking branch 'origin/master' into POC/calendar-widget…
JakubSerafin Aug 8, 2023
269605c
package.json updted
JakubSerafin Aug 8, 2023
7177773
cleanup of getGrist.ts
JakubSerafin Aug 8, 2023
302ca76
tab size and imports in getGrist.ts fixed
JakubSerafin Aug 8, 2023
6a80bf5
main.yml improved to allow binding to the working server during tests
JakubSerafin Aug 8, 2023
a02880d
package.json fixed
JakubSerafin Aug 8, 2023
e51d004
Update main.yml
JakubSerafin Aug 8, 2023
06f7135
without changes in build, in test production widget are used.
JakubSerafin Aug 8, 2023
01b83fd
fix for build:test to generate not only manifest but also actually bu…
JakubSerafin Aug 8, 2023
d7df3c7
test rewritten to not really on any pre-set data.
JakubSerafin Aug 9, 2023
a916f83
file replaced
JakubSerafin Aug 9, 2023
d282ac3
whole day test fix
JakubSerafin Aug 9, 2023
98f92c1
test if this would fix on the StaleElementReferenceError: stale eleme…
JakubSerafin Aug 10, 2023
5b2708d
Update calendar.ts
JakubSerafin Aug 10, 2023
327a72d
Update getGrist.ts
JakubSerafin Aug 10, 2023
114ce45
awaiting in setCustomWidgetMapping
JakubSerafin Aug 16, 2023
b711366
widget name changed from calendar-map to widget-calendar
JakubSerafin Aug 16, 2023
9341245
widget name changed from calendar-map to widget-calendar
JakubSerafin Aug 16, 2023
6c9a829
Calendar name separated to const
JakubSerafin Aug 16, 2023
6906ae8
read function improved to a more modern version
JakubSerafin Aug 16, 2023
476612a
rewording comment to convertEventToGristTableFormat
JakubSerafin Aug 16, 2023
2ac80fa
various typos and
JakubSerafin Aug 16, 2023
453b3a2
more typos and code quality fixes
JakubSerafin Aug 16, 2023
85e3064
unit tests fixes, waiting for the calendar being refreshed before ass…
JakubSerafin Aug 16, 2023
850d5c2
Merge remote-tracking branch 'origin/POC/calendar-widget-poc' into PO…
JakubSerafin Aug 16, 2023
c407635
revert unnecessary changes
JakubSerafin Aug 17, 2023
727e2cf
sendActionsAndWaitForServer goes from string to function
JakubSerafin Aug 17, 2023
d5ec8de
removing unused function
JakubSerafin Aug 17, 2023
301fb98
tests for changing perspective and date
JakubSerafin Aug 17, 2023
5918966
filtering data for table.update
JakubSerafin Aug 17, 2023
d71488b
build script fixes, cheking record types when grist selection is updated
JakubSerafin Aug 17, 2023
e24c24a
review fixes
JakubSerafin Aug 18, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
run: yarn install

- name: Build Node.js test code
run: yarn run build
run: yarn run build:test
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved

- name: Cache Docker images.
uses: ScribeMD/[email protected]
Expand Down
33 changes: 33 additions & 0 deletions calendar/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<html lang="en">
<head>
<link rel="stylesheet" href="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.css" />
<link rel="stylesheet" href="screen.css" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.js"></script>
<script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
</head>
<body>
<div id="calendar-container">
<div id="calendar-options">
<div id="calendar-navigation-buttons">
<button class="calendar-button" onclick="calendarHandler.calendarPrevious()"><</button>
<button class="calendar-button" onclick="calendarHandler.calendarToday()">Today</button>
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
<button class="calendar-button" onclick="calendarHandler.calendarNext()">></button>
</div>
<div id="calendar-perspective-buttons">
<input type="radio" id="calendar-day" name="calendar-options" value="day" onchange="calendarViewChanges(this)">
<label class="calendar-button" for="calendar-day">Day</label>
<input type="radio" id="calenar-week" name="calendar-options" value="week" onchange="calendarViewChanges(this)">
<label class="calendar-button" for="calenar-week">Week</label>
<input type="radio" id="calenar-month" name="calendar-options" value="month" onchange="calendarViewChanges(this)">
<label class="calendar-button" for="calenar-month">Month</label>
</div>
</div>
<div id="calendar">
</div>
</div>
</body>
<script src="page.js"></script>

</html>
19 changes: 19 additions & 0 deletions calendar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@gristlabs/calendar-map",
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
"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#calendar",
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
"published": true,
"accessLevel": "read table"
}

]
}
286 changes: 286 additions & 0 deletions calendar/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
// lets assume that it's imported in a html file
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
var grist;
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved

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 `<span>${title}</span>`;
},
allday(event) {
const {title} = event;
return `<span>${title}</span>`;
},
},
calendars: [
{
id: 'cal1',
name: 'Personal',
backgroundColor: CalendarHandler._mainColor,
},
],
};
}
constructor() {
const container = document.getElementById('calendar');
const options = CalendarHandler.getCalendarOptions();
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', (info)=> {
onNewDateBeingSelectedOnCalendar(info);
this.calendar.clearGridSelections();
});
}
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved

// navigate to the selected date in the calendar and scroll to the time period of the event
selectRecord(record) {
if (this._selectedRecordId) {
this.calendar.updateEvent(this._selectedRecordId, 'cal1', {backgroundColor: CalendarHandler._mainColor});
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
}
this.calendar.updateEvent(record.id, 'cal1', {backgroundColor: CalendarHandler._selectedColor});
this._selectedRecordId = record.id;
this.calendar.setDate(record.startDate);
var dom = document.querySelector('.toastui-calendar-time');
const middleHour = record.startDate.getHours()
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
+ (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 of 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, 'cal1');
const eventData = record;
if (!event) {
this.calendar.createEvents([eventData]);
} else {
this.calendar.updateEvent(record.id, 'cal1', 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) {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
for (const id of this.previousIds) {
if (!currentIds.has(id)) {
this.calendar.deleteEvent(id, 'cal1');
}
}
}
this.previousIds = currentIds;
}
}

// when document is ready, register calendar and subscribe to grist events
document.addEventListener('DOMContentLoaded', ()=> {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
this.calendarHandler = new CalendarHandler();
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
configureGristSettings();
});

//to update the table, grist require other format that it is returning in onRecords event (it's flat there),
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
// so it need to be converted
function convertEventToGristTableFormat(event) {
const mappedRecord = grist.mapColumnNamesBack(event);
// we cannot save record is some unexpected columns are defined in fields, so we need to remove them
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
delete mappedRecord.id;
return { id: event.id, fields: mappedRecord };
}

// Data for column mapping fileds in Widget GUI
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
function getGristOptions() {
return [
{
name: "startDate",
title: "Start Date",
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
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 subscribe all the events that we need
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
async function configureGristSettings() {
// table selection should change when other 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)
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
grist.onOptions(onGristSettingsChanged);

// bind columns mapping options to the GUI
const columnsMappingOptions = getGristOptions();
grist.ready({ requiredAccess: 'read table', columns: columnsMappingOptions });
}

// when user select record in the table, we want to select it on the calendar
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
function gristSelectedRecordChanged(record, mappings) {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
const mappedRecord = grist.mapColumnNames(record, mappings);
if (mappedRecord && this.calendarHandler) {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
this.calendarHandler.selectRecord(mappedRecord);
}
}

// when user change the perspective in the GUI, we want to save it as grist option
// - rest of logic is in reaction to grist option changed
async function calendarViewChanges(radiobutton) {
await grist.setOption('calendarViewPerspective', radiobutton.value);
}


// when user change 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';
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
this.calendarHandler.changeView(option);
selectRadioButton(option);
};

// when user move or resize event on the calendar, we want to update the record in the table
const onCalendarEventBeingUpdated = async (info) => {
if (info.changes?.start || info.changes?.end) {
const record = await grist.fetchSelectedRecord(info.event.id);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm why do we need to refetch the record from the application? Can't we just update the start and end?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was my first approach, but TableOperations.update() seems to nullify all fields that are absent in argument.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems bad, is this a bug in TableOperations.update? Fetching, patching and re-saving would leave any user of this api prone to races.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, it was not a bug in TableOperations.update but rather specific way in with grist.mapColumnNamesBack works. It map back all fields that was in source object, but for absent fields it just put a "undefined" under the target object value for given key.
It's up to discussion if grist.mapColumnNamesBack should work that way

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thoughts on what grist.mapColumnNamesBack should do for absent fields @berhalak ?

if (record) {
// get all the record data from the event, and update only start and end date
const gristEvent = buildGristFlatFormatFromEventObject(info.event)
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){
const eventInValidFormat = convertEventToGristTableFormat(gristEvent);
const table = await grist.getTable();
if (gristEvent.id) {
await table.update(eventInValidFormat);
} else {
await table.create(eventInValidFormat);
}
}

// grist expect date in seconds, but calendar is returning it in miliseconds, 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) {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
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;
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
return gristEvent;
}

// when user select new date range on the calendar, we want to create new record in the table
const onNewDateBeingSelectedOnCalendar = async (info) => {
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
const gristEvent = buildGristFlatFormatFromEventObject(info);
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 calendar event object from grist flat record
function buildCalendarEventObject(record) {
return {
id: record.id,
calendarId: 'cal1',
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 calendar
async function updateCalendar(records, mappings) {
const mappedRecords = grist.mapColumnNames(records, mappings);
// if any records was successfully mapped, create or update them in the calendar
JakubSerafin marked this conversation as resolved.
Show resolved Hide resolved
if (mappedRecords) {
const CalendarEventObjects = mappedRecords.map(buildCalendarEventObject);
await this.calendarHandler.updateCalendarEvents(CalendarEventObjects);
}
}
Loading