From 64ce7b14a5967a16ee7783580f7bdb138a918d61 Mon Sep 17 00:00:00 2001 From: wa0x6e Date: Sat, 24 Dec 2022 20:56:46 +0400 Subject: [PATCH] refactor: All asynchronous public methods should return a Promise --- src/CalHeatmap.ts | 116 ++++++++++--------- src/calendar/CalendarPainter.ts | 4 + src/options/Options.ts | 4 +- test/frontend/methods.test.ts | 194 ++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 55 deletions(-) create mode 100644 test/frontend/methods.test.ts diff --git a/src/CalHeatmap.ts b/src/CalHeatmap.ts index 9436044a..b89271e5 100755 --- a/src/CalHeatmap.ts +++ b/src/CalHeatmap.ts @@ -43,10 +43,10 @@ export default class CalHeatmap { helpers: Helpers; constructor() { - // Default settings + // Default options this.options = new Options(); - // Init the helpers with the default settings + // Init the helpers with the default options this.helpers = createHelpers(this.options); this.templateCollection = new TemplateCollection( this.helpers, @@ -80,11 +80,12 @@ export default class CalHeatmap { /** * Setup and paint the calendar with the given options * - * @param {Object} settings Options - * @return {boolean} True, unless there's an error + * @param {Object} options The Options object + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ - paint(settings: DeepPartial): boolean { - this.options.init(settings); + paint(options?: DeepPartial): Promise { + this.options.init(options); // Refresh the helpers with the correct options this.helpers = createHelpers(this.options); @@ -93,9 +94,7 @@ export default class CalHeatmap { try { validate(this.templateCollection, this.options.options); } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return false; + return Promise.reject(error); } this.calendarPainter.setup(); @@ -109,57 +108,61 @@ export default class CalHeatmap { this.options.options.range, ), ); - this.calendarPainter.paint(); - this.fill(); - - return true; + return Promise.allSettled([ + this.calendarPainter.paint(), + this.fill(), + ]); } /** * Add a new subDomainTemplate * * @since 4.0.0 - * @param {Array | SubDomainTemplate} templates + * @param {SubDomainTemplate[] | SubDomainTemplate} templates * A single, or an array of SubDomainTemplate object * @return void */ - addTemplates(templates: Template | Template[]) { + addTemplates(templates: Template | Template[]): void { this.templateCollection.add(templates); } /** * Shift the calendar by n domains forward * - * @param {number} Number of domain interval to shift + * @param {number} n Number of domain intervals to shift forward + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ - next(n: number = 1) { + next(n: number = 1): Promise { const loadDirection = this.navigator.loadNewDomains( this.createDomainCollection(this.domainCollection.max, n + 1).slice(n), ScrollDirection.SCROLL_FORWARD, ); - const promise = this.calendarPainter.paint(loadDirection); - // @TODO: Update only newly inserted domains - this.fill(); - return promise; + return Promise.allSettled([ + this.calendarPainter.paint(loadDirection), + this.fill(), + ]); } /** * Shift the calendar by n domains backward * - * @param {number} Number of domain interval to shift + * @param {number} n Number of domain intervals to shift backward + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ - previous(n: number = 1) { + previous(n: number = 1): Promise { const loadDirection = this.navigator.loadNewDomains( this.createDomainCollection(this.domainCollection.min, -n), ScrollDirection.SCROLL_BACKWARD, ); - const promise = this.calendarPainter.paint(loadDirection); - // @TODO: Update only newly inserted domains - this.fill(); - return promise; + return Promise.allSettled([ + this.calendarPainter.paint(loadDirection), + this.fill(), + ]); } /** @@ -167,29 +170,30 @@ export default class CalHeatmap { * * JumpTo will scroll the calendar until the wanted domain with the specified * date is visible. Unless you set reset to true, the wanted domain - * will not necessarily be the first (leftmost) domain of the calendar. + * will not necessarily be the first domain of the calendar. * - * @param Date date Jump to the domain containing that date + * @param {Date} date Jump to the domain containing that date * @param {boolean} reset Whether the wanted domain * should be the first domain of the calendar - * @param {boolean} True of the calendar was scrolled + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ - jumpTo(date: Date, reset: boolean = false) { - const loadDirection = this.navigator.jumpTo(date, reset); - const promise = this.calendarPainter.paint(loadDirection); - // @TODO: Update only newly inserted domains - this.fill(); - - return promise; + jumpTo(date: Date, reset: boolean = false): Promise { + return Promise.allSettled([ + this.calendarPainter.paint(this.navigator.jumpTo(date, reset)), + this.fill(), + ]); } /** - * Fill the calendar with some data + * Fill the calendar with the given data * - * @param {object|string} dataSource The calendar's datasource, - * same type as this.options.data.source + * @param {Object|string} dataSource The calendar's datasource, + * same type as `options.data.source` + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ - fill(dataSource = this.options.options.data.source): void { + fill(dataSource = this.options.options.data.source): Promise { const { options } = this.options; const template = this.templateCollection; const endDate = this.helpers.DateHelper.intervals( @@ -204,16 +208,21 @@ export default class CalHeatmap { endDate, ); - dataPromise.then((data: any) => { - this.domainCollection.fill( - data, - options.data, - this.domainCollection.min, - endDate, - template.get(options.domain.type)!.extractUnit, - template.get(options.subDomain.type)!.extractUnit, - ); - this.populator.populate(); + return new Promise((resolve, reject) => { + dataPromise.then((data: any) => { + this.domainCollection.fill( + data, + options.data, + this.domainCollection.min, + endDate, + template.get(options.domain.type)!.extractUnit, + template.get(options.subDomain.type)!.extractUnit, + ); + this.populator.populate(); + resolve(null); + }, (error) => { + reject(error); + }); }); } @@ -221,7 +230,7 @@ export default class CalHeatmap { * Listener for all events * * @since 4.0.0 - * @param {string} eventName Name of the event to listen to + * @param {string} eventName Name of the event to listen to * @param {function} Callback function to execute on event trigger * @return void */ @@ -233,7 +242,8 @@ export default class CalHeatmap { * Destroy the calendar * * @since 3.3.6 - * @return Promise + * @return A Promise, which will fulfill once all the underlying asynchronous + * tasks settle, whether resolved or rejected. */ destroy(): Promise { return this.calendarPainter.destroy(); diff --git a/src/calendar/CalendarPainter.ts b/src/calendar/CalendarPainter.ts index f70a9d9a..83d9638f 100644 --- a/src/calendar/CalendarPainter.ts +++ b/src/calendar/CalendarPainter.ts @@ -137,6 +137,10 @@ export default class CalendarPainter { } destroy(): Promise { + if (!this.root) { + return Promise.resolve(); + } + this.legendPainter.destroy(); this.tooltip.destroy(); diff --git a/src/options/Options.ts b/src/options/Options.ts index 4c21683f..9da7ab0b 100644 --- a/src/options/Options.ts +++ b/src/options/Options.ts @@ -306,10 +306,10 @@ export default class Options { return true; } - init(settings: any): void { + init(opts: any): void { this.options = { // eslint-disable-next-line arrow-body-style - ...mergeWith(this.options, settings, (objValue, srcValue) => { + ...mergeWith(this.options, opts, (objValue, srcValue) => { return Array.isArray(srcValue) ? srcValue : undefined; }), }; diff --git a/test/frontend/methods.test.ts b/test/frontend/methods.test.ts new file mode 100644 index 00000000..c217f96c --- /dev/null +++ b/test/frontend/methods.test.ts @@ -0,0 +1,194 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals'; +import { select } from 'd3-selection'; + +/** + * @jest-environment jsdom + */ + +import CalHeatmap from '../../src/CalHeatmap'; + +describe('Methods', () => { + let cal: CalHeatmap; + beforeEach(() => { + cal = new CalHeatmap(); + select('body').append('div').attr('id', 'cal-heatmap'); + }); + + afterEach(() => { + cal.destroy(); + document.getElementsByTagName('html')[0].innerHTML = ''; + }); + + describe('paint()', () => { + it('returns a Promise', () => { + expect(cal.paint()).toBeInstanceOf(Promise); + }); + + it('returns a rejected Promise on options critical error', async () => { + await expect(cal.paint({ domain: { type: 'hello' } })).rejects.toThrow(); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that fully resolve on success', () => { + // @ts-ignore + return cal.paint().then((data) => { + // @ts-ignore + data.forEach((pr) => { + // @ts-ignore + expect(pr.status).toBe('fulfilled'); + }); + }); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that contains a rejected promise', () => { + // @ts-ignore + return cal.paint({ data: { source: 'unknown.csv' } }).then((data) => { + // @ts-ignore + const r = data.map((pr) => pr.status); + + expect(r).toContain('fulfilled'); + expect(r).toContain('rejected'); + }); + }); + }); + + describe('next()', () => { + beforeEach(() => { + cal.paint(); + }); + + it('returns a Promise', () => { + expect(cal.next()).toBeInstanceOf(Promise); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that fully resolve on success', () => { + // @ts-ignore + return cal.next().then((data) => { + // @ts-ignore + data.forEach((pr) => { + // @ts-ignore + expect(pr.status).toBe('fulfilled'); + }); + }); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that contains a rejected promise', () => { + cal.fill = jest.fn(() => Promise.reject()); + + // @ts-ignore + return cal.next().then((data) => { + // @ts-ignore + const r = data.map((pr) => pr.status); + + expect(r).toContain('fulfilled'); + expect(r).toContain('rejected'); + }); + }); + }); + + describe('previous()', () => { + beforeEach(() => { + cal.paint(); + }); + + it('returns a Promise', () => { + expect(cal.previous()).toBeInstanceOf(Promise); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that fully resolve on success', () => { + // @ts-ignore + return cal.previous().then((data) => { + // @ts-ignore + data.forEach((pr) => { + // @ts-ignore + expect(pr.status).toBe('fulfilled'); + }); + }); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that contains a rejected promise', () => { + cal.fill = jest.fn(() => Promise.reject()); + + // @ts-ignore + return cal.previous().then((data) => { + // @ts-ignore + const r = data.map((pr) => pr.status); + + expect(r).toContain('fulfilled'); + expect(r).toContain('rejected'); + }); + }); + }); + + describe('jumpTo()', () => { + beforeEach(() => { + cal.paint(); + }); + + it('returns a Promise', () => { + expect(cal.jumpTo(new Date())).toBeInstanceOf(Promise); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that fully resolve on success', () => { + // @ts-ignore + return cal.jumpTo(new Date()).then((data) => { + // @ts-ignore + data.forEach((pr) => { + // @ts-ignore + expect(pr.status).toBe('fulfilled'); + }); + }); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a Promise that contains a rejected promise', () => { + cal.fill = jest.fn(() => Promise.reject()); + + // @ts-ignore + return cal.jumpTo(new Date()).then((data) => { + // @ts-ignore + const r = data.map((pr) => pr.status); + + expect(r).toContain('fulfilled'); + expect(r).toContain('rejected'); + }); + }); + }); + + describe('destroy()', () => { + beforeEach(() => { + cal.paint(); + }); + + it('returns a Promise', () => { + expect(cal.destroy()).toBeInstanceOf(Promise); + }); + }); + + describe('fill()', () => { + beforeEach(() => { + cal.paint(); + }); + + it('returns a Promise', () => { + expect(cal.fill()).toBeInstanceOf(Promise); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a resolved promise', () => { + return expect(cal.fill({})).resolves.toBe(null); + }); + + // eslint-disable-next-line arrow-body-style + it('returns a rejected Promise', () => { + return expect(cal.fill('data.csv')).rejects.toThrow(); + }); + }); +});