From 5a947e53ef3167ec5cef3432618c89ae67598c8d Mon Sep 17 00:00:00 2001 From: mario Date: Sat, 4 Feb 2023 13:25:44 +0100 Subject: [PATCH 1/8] implement `notification` action --- src/actions.ts | 13 +-- src/actions/notification.ts | 40 +++++++++ .../notifications/create_notification.test.js | 88 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 src/actions/notification.ts create mode 100644 test/notifications/create_notification.test.js diff --git a/src/actions.ts b/src/actions.ts index 55a8745..6b76405 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -2,16 +2,17 @@ import { TurboStreamActions } from "@hotwired/turbo" import * as Attributes from "./actions/attributes" import * as Browser from "./actions/browser" -import * as Document from "./actions/document" -import * as DOM from "./actions/dom" import * as Debug from "./actions/debug" import * as Deprecated from "./actions/deprecated" +import * as Document from "./actions/document" +import * as DOM from "./actions/dom" import * as Events from "./actions/events" import * as Form from "./actions/form" import * as History from "./actions/history" +import * as Notification from "./actions/notification" import * as Storage from "./actions/storage" -import * as TurboFrame from "./actions/turbo_frame" import * as Turbo from "./actions/turbo" +import * as TurboFrame from "./actions/turbo_frame" export * from "./actions/attributes" export * from "./actions/browser" @@ -22,9 +23,10 @@ export * from "./actions/dom" export * from "./actions/events" export * from "./actions/form" export * from "./actions/history" +export * from "./actions/notification" export * from "./actions/storage" -export * from "./actions/turbo_frame" export * from "./actions/turbo" +export * from "./actions/turbo_frame" export function register(streamActions: TurboStreamActions) { Attributes.registerAttributesActions(streamActions) @@ -36,7 +38,8 @@ export function register(streamActions: TurboStreamActions) { Events.registerEventsActions(streamActions) Form.registerFormActions(streamActions) History.registerHistoryActions(streamActions) + Notification.registerNotificationActions(streamActions) Storage.registerStorageActions(streamActions) - TurboFrame.registerTurboFrameActions(streamActions) Turbo.registerTurboActions(streamActions) + TurboFrame.registerTurboFrameActions(streamActions) } diff --git a/src/actions/notification.ts b/src/actions/notification.ts new file mode 100644 index 0000000..78d69a7 --- /dev/null +++ b/src/actions/notification.ts @@ -0,0 +1,40 @@ +import { StreamElement, TurboStreamActions } from "@hotwired/turbo" + +type AttributeToNotificationOptionKeyMappingRow = [string, string, boolean] + +const ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING: AttributeToNotificationOptionKeyMappingRow[] = [ + ["dir", "dir", false], + ["lang", "lang", false], + ["badge", "badge", false], + ["body", "body", false], + ["tag", "tag", false], + ["icon", "icon", false], + ["image", "image", false], + ["data", "data", true], + ["vibrate", "vibrate", true], + ["renotify", "renotify", true], + ["require-interaction", "requireInteraction", true], + ["actions", "actions", true], + ["silent", "silent", true], +] + +export function notification(this: StreamElement) { + const title = this.getAttribute("title") || "" + + const options = ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING.reduce((acc, [attributeName, optionKey, parseJson]) => { + const attributeValue = this.getAttribute(attributeName) + + if (attributeValue !== null) { + const optionValue = parseJson ? JSON.parse(attributeValue) : attributeValue + return { ...acc, [optionKey]: optionValue } + } else { + return acc + } + }, {}) + + new Notification(title, options) +} + +export function registerNotificationActions(streamActions: TurboStreamActions) { + streamActions.notification = notification +} diff --git a/test/notifications/create_notification.test.js b/test/notifications/create_notification.test.js new file mode 100644 index 0000000..a4e6c87 --- /dev/null +++ b/test/notifications/create_notification.test.js @@ -0,0 +1,88 @@ +import sinon from "sinon" +import { assert } from "@open-wc/testing" +import { executeStream, registerAction } from "../test_helpers" + +registerAction("notification") + +function notificationSpy() { + return sinon.spy(window, "Notification") +} + +describe("notification", () => { + afterEach(() => { + sinon.restore() + }) + + it("should create the notification, title only", async () => { + const spy = notificationSpy() + + await executeStream( + `` + ) + + assert.equal(spy.callCount, 1) + assert.equal(spy.firstCall.firstArg, "May I have your attention...") + assert.equal(spy.firstCall.secondArg, null) + }) + + it("should create the notification, title and options", async () => { + const spy = notificationSpy() + + await executeStream( + `` + ) + + assert.equal(spy.callCount, 1) + assert.equal(spy.firstCall.firstArg, "May I have your attention...") + assert.deepEqual(spy.firstCall.args[1], { + dir: "ltr", + lang: "EN", + badge: "https://example.com/badge.png", + body: "This is displayed below the title.", + tag: "Demo", + icon: "https://example.com/icon.png", + image: "https://example.com/image.png", + data: { arbitrary: "data" }, + vibrate: [200, 100, 200], + renotify: true, + requireInteraction: true, + }) + }) + + it.skip("should create the notification, title and options that are supported using ServiceWorkers", async () => { + const spy = notificationSpy() + + await executeStream( + `` + ) + + assert.equal(spy.callCount, 1) + assert.equal(spy.firstCall.firstArg, "May I have your attention...") + assert.deepEqual(spy.firstCall.args[1], { + actions: [{ action: "respond", title: "Please respond", icon: "https://example.com/icon.png" }], + silent: true, + }) + }) +}) From 3a1a70deb248637a95e6b41ce00f56efa9c9e5af Mon Sep 17 00:00:00 2001 From: mario Date: Wed, 8 Feb 2023 13:49:17 +0100 Subject: [PATCH 2/8] replace spy with stub --- .../notifications/create_notification.test.js | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/test/notifications/create_notification.test.js b/test/notifications/create_notification.test.js index a4e6c87..053df8d 100644 --- a/test/notifications/create_notification.test.js +++ b/test/notifications/create_notification.test.js @@ -4,8 +4,8 @@ import { executeStream, registerAction } from "../test_helpers" registerAction("notification") -function notificationSpy() { - return sinon.spy(window, "Notification") +function notificationStub() { + return sinon.stub(window, "Notification") } describe("notification", () => { @@ -14,7 +14,7 @@ describe("notification", () => { }) it("should create the notification, title only", async () => { - const spy = notificationSpy() + const stub = notificationStub() await executeStream( ` { >` ) - assert.equal(spy.callCount, 1) - assert.equal(spy.firstCall.firstArg, "May I have your attention...") - assert.equal(spy.firstCall.secondArg, null) + assert.equal(stub.callCount, 1) + assert.equal(stub.firstCall.firstArg, "May I have your attention...") + assert.equal(stub.firstCall.secondArg, null) }) it("should create the notification, title and options", async () => { - const spy = notificationSpy() + const stub = notificationStub() await executeStream( ` { vibrate="[200, 100, 200]" renotify="true" require-interaction="true" + actions='[{"action": "respond", "title": "Please respond", "icon": "https://example.com/icon.png"}]' + silent="true" >` ) - assert.equal(spy.callCount, 1) - assert.equal(spy.firstCall.firstArg, "May I have your attention...") - assert.deepEqual(spy.firstCall.args[1], { + assert.equal(stub.callCount, 1) + assert.equal(stub.firstCall.firstArg, "May I have your attention...") + assert.deepEqual(stub.firstCall.args[1], { dir: "ltr", lang: "EN", badge: "https://example.com/badge.png", @@ -63,24 +65,6 @@ describe("notification", () => { vibrate: [200, 100, 200], renotify: true, requireInteraction: true, - }) - }) - - it.skip("should create the notification, title and options that are supported using ServiceWorkers", async () => { - const spy = notificationSpy() - - await executeStream( - `` - ) - - assert.equal(spy.callCount, 1) - assert.equal(spy.firstCall.firstArg, "May I have your attention...") - assert.deepEqual(spy.firstCall.args[1], { actions: [{ action: "respond", title: "Please respond", icon: "https://example.com/icon.png" }], silent: true, }) From 4878d31b3095741db22530373441d168b8bdd46e Mon Sep 17 00:00:00 2001 From: mario Date: Fri, 10 Feb 2023 16:04:38 +0100 Subject: [PATCH 3/8] wip, todo: rewrite notification() --- src/actions/notification.ts | 22 ++++++-- .../notifications/create_notification.test.js | 56 ++++++++++++++++--- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/actions/notification.ts b/src/actions/notification.ts index 78d69a7..25c1c9c 100644 --- a/src/actions/notification.ts +++ b/src/actions/notification.ts @@ -10,7 +10,7 @@ const ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING: AttributeToNotificationOptio ["tag", "tag", false], ["icon", "icon", false], ["image", "image", false], - ["data", "data", true], + ["data", "data", false], ["vibrate", "vibrate", true], ["renotify", "renotify", true], ["require-interaction", "requireInteraction", true], @@ -18,11 +18,11 @@ const ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING: AttributeToNotificationOptio ["silent", "silent", true], ] -export function notification(this: StreamElement) { - const title = this.getAttribute("title") || "" +const createNotification = (streamElement: StreamElement) => { + const title = streamElement.getAttribute("title") || "" const options = ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING.reduce((acc, [attributeName, optionKey, parseJson]) => { - const attributeValue = this.getAttribute(attributeName) + const attributeValue = streamElement.getAttribute(attributeName) if (attributeValue !== null) { const optionValue = parseJson ? JSON.parse(attributeValue) : attributeValue @@ -35,6 +35,20 @@ export function notification(this: StreamElement) { new Notification(title, options) } +export function notification(this: StreamElement) { + if (!window.Notification) { + alert("This browser does not support desktop notification") + } else if (Notification.permission === "granted") { + createNotification(this) + } else if (Notification.permission !== "denied") { + Notification.requestPermission().then((permission) => { + if (permission === "granted") { + createNotification(this) + } + }) + } +} + export function registerNotificationActions(streamActions: TurboStreamActions) { streamActions.notification = notification } diff --git a/test/notifications/create_notification.test.js b/test/notifications/create_notification.test.js index 053df8d..96eb6fb 100644 --- a/test/notifications/create_notification.test.js +++ b/test/notifications/create_notification.test.js @@ -4,17 +4,43 @@ import { executeStream, registerAction } from "../test_helpers" registerAction("notification") -function notificationStub() { - return sinon.stub(window, "Notification") -} - describe("notification", () => { afterEach(() => { sinon.restore() }) - it("should create the notification, title only", async () => { - const stub = notificationStub() + it("grants permission after default", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("default") + sinon.stub(Notification, "requestPermission").resolves("granted") + + await executeStream(``) + + assert.equal(stub.callCount, 1) + }) + + it("rejects permission after default", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("default") + sinon.stub(Notification, "requestPermission").resolves("denied") + + await executeStream(``) + + assert.equal(stub.callCount, 0) + }) + + it("creates no notification if denied", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("denied") + + await executeStream(``) + + assert.equal(stub.callCount, 0) + }) + + it("creates the notifiaction if granted", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("granted") await executeStream( ` { }) it("should create the notification, title and options", async () => { - const stub = notificationStub() + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("granted") await executeStream( ` { tag: "Demo", icon: "https://example.com/icon.png", image: "https://example.com/image.png", - data: { arbitrary: "data" }, + data: '{"arbitrary": "data"}', vibrate: [200, 100, 200], renotify: true, requireInteraction: true, @@ -69,4 +96,17 @@ describe("notification", () => { silent: true, }) }) + + it("handles if Notification is not available", async () => { + const stub = sinon.stub(window, "Notification").value(undefined) + const mock = sinon + .mock(window) + .expects("alert") + .once() + .withArgs("This browser does not support desktop notification") + + await executeStream(``) + assert.equal(stub.callCount, 0) + mock.verify() + }) }) From 1b27778f9e65b3765addd1ef960e586b78c32fbe Mon Sep 17 00:00:00 2001 From: mario Date: Fri, 10 Feb 2023 22:17:51 +0100 Subject: [PATCH 4/8] refactor notification.ts --- src/actions/notification.ts | 48 ++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/actions/notification.ts b/src/actions/notification.ts index 25c1c9c..6b45948 100644 --- a/src/actions/notification.ts +++ b/src/actions/notification.ts @@ -1,36 +1,30 @@ import { StreamElement, TurboStreamActions } from "@hotwired/turbo" - -type AttributeToNotificationOptionKeyMappingRow = [string, string, boolean] - -const ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING: AttributeToNotificationOptionKeyMappingRow[] = [ - ["dir", "dir", false], - ["lang", "lang", false], - ["badge", "badge", false], - ["body", "body", false], - ["tag", "tag", false], - ["icon", "icon", false], - ["image", "image", false], - ["data", "data", false], - ["vibrate", "vibrate", true], - ["renotify", "renotify", true], - ["require-interaction", "requireInteraction", true], - ["actions", "actions", true], - ["silent", "silent", true], +import { camelize, typecast } from "../utils" + +const PERMITTED_ATTRIBUTES = [ + "dir", + "lang", + "badge", + "body", + "tag", + "icon", + "image", + "data", + "vibrate", + "renotify", + "require-interaction", + "actions", + "silent", ] const createNotification = (streamElement: StreamElement) => { const title = streamElement.getAttribute("title") || "" - const options = ATTRIBUTE_TO_NOTIFICATION_OPTION_KEY_MAPPING.reduce((acc, [attributeName, optionKey, parseJson]) => { - const attributeValue = streamElement.getAttribute(attributeName) - - if (attributeValue !== null) { - const optionValue = parseJson ? JSON.parse(attributeValue) : attributeValue - return { ...acc, [optionKey]: optionValue } - } else { - return acc - } - }, {}) + const options = Array.from(streamElement.attributes) + .filter((attribute) => PERMITTED_ATTRIBUTES.includes(attribute.name)) + .reduce((acc, attribute) => { + return { ...acc, [camelize(attribute.name)]: typecast(attribute.value) } + }, {}) new Notification(title, options) } From e21c4afb911ce1581054900f99651c23e0716837 Mon Sep 17 00:00:00 2001 From: mario Date: Fri, 10 Feb 2023 22:18:05 +0100 Subject: [PATCH 5/8] add contexts to tests for notifications --- .../notifications/create_notification.test.js | 176 +++++++++--------- 1 file changed, 92 insertions(+), 84 deletions(-) diff --git a/test/notifications/create_notification.test.js b/test/notifications/create_notification.test.js index 96eb6fb..a6ac9ff 100644 --- a/test/notifications/create_notification.test.js +++ b/test/notifications/create_notification.test.js @@ -9,104 +9,112 @@ describe("notification", () => { sinon.restore() }) - it("grants permission after default", async () => { - const stub = sinon.stub(window, "Notification") - sinon.stub(Notification, "permission").value("default") - sinon.stub(Notification, "requestPermission").resolves("granted") - - await executeStream(``) - - assert.equal(stub.callCount, 1) + context("Notification is not supported", () => { + it("alerts with a message", async () => { + const stub = sinon.stub(window, "Notification").value(undefined) + const mock = sinon + .mock(window) + .expects("alert") + .once() + .withArgs("This browser does not support desktop notification") + + await executeStream(``) + assert.equal(stub.callCount, 0) + mock.verify() + }) }) - it("rejects permission after default", async () => { - const stub = sinon.stub(window, "Notification") - sinon.stub(Notification, "permission").value("default") - sinon.stub(Notification, "requestPermission").resolves("denied") + context("requestPermission is required", () => { + it("permission is granted", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("default") + sinon.stub(Notification, "requestPermission").resolves("granted") - await executeStream(``) + await executeStream(``) - assert.equal(stub.callCount, 0) - }) + assert.equal(stub.callCount, 1) + }) - it("creates no notification if denied", async () => { - const stub = sinon.stub(window, "Notification") - sinon.stub(Notification, "permission").value("denied") + it("permission is denied", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("default") + sinon.stub(Notification, "requestPermission").resolves("denied") - await executeStream(``) + await executeStream(``) - assert.equal(stub.callCount, 0) + assert.equal(stub.callCount, 0) + }) }) - it("creates the notifiaction if granted", async () => { - const stub = sinon.stub(window, "Notification") - sinon.stub(Notification, "permission").value("granted") + context("permission was denied", () => { + it("creates no notification", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("denied") - await executeStream( - `` - ) + await executeStream(``) - assert.equal(stub.callCount, 1) - assert.equal(stub.firstCall.firstArg, "May I have your attention...") - assert.equal(stub.firstCall.secondArg, null) + assert.equal(stub.callCount, 0) + }) }) - it("should create the notification, title and options", async () => { - const stub = sinon.stub(window, "Notification") - sinon.stub(Notification, "permission").value("granted") - - await executeStream( - `` - ) - - assert.equal(stub.callCount, 1) - assert.equal(stub.firstCall.firstArg, "May I have your attention...") - assert.deepEqual(stub.firstCall.args[1], { - dir: "ltr", - lang: "EN", - badge: "https://example.com/badge.png", - body: "This is displayed below the title.", - tag: "Demo", - icon: "https://example.com/icon.png", - image: "https://example.com/image.png", - data: '{"arbitrary": "data"}', - vibrate: [200, 100, 200], - renotify: true, - requireInteraction: true, - actions: [{ action: "respond", title: "Please respond", icon: "https://example.com/icon.png" }], - silent: true, + context("permission was granted", () => { + it("creates the notification with title only", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("granted") + + await executeStream( + `` + ) + + assert.equal(stub.callCount, 1) + assert.equal(stub.firstCall.firstArg, "May I have your attention...") + assert.equal(stub.firstCall.secondArg, null) }) - }) - it("handles if Notification is not available", async () => { - const stub = sinon.stub(window, "Notification").value(undefined) - const mock = sinon - .mock(window) - .expects("alert") - .once() - .withArgs("This browser does not support desktop notification") - - await executeStream(``) - assert.equal(stub.callCount, 0) - mock.verify() + it("creates the notification with title and options", async () => { + const stub = sinon.stub(window, "Notification") + sinon.stub(Notification, "permission").value("granted") + + await executeStream( + `` + ) + + assert.equal(stub.callCount, 1) + assert.equal(stub.firstCall.firstArg, "May I have your attention...") + assert.deepEqual(stub.firstCall.args[1], { + dir: "ltr", + lang: "EN", + badge: "https://example.com/badge.png", + body: "This is displayed below the title.", + tag: "Demo", + icon: "https://example.com/icon.png", + image: "https://example.com/image.png", + data: { arbitrary: "data" }, + vibrate: [200, 100, 200], + renotify: true, + requireInteraction: true, + actions: [{ action: "respond", title: "Please respond", icon: "https://example.com/icon.png" }], + silent: true, + }) + }) }) }) From e2b52743d52700c259ea0292861ef9aa43cf3046 Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 11 Feb 2023 09:42:53 +0100 Subject: [PATCH 6/8] Update src/actions/notification.ts Co-authored-by: Marco Roth --- src/actions/notification.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/actions/notification.ts b/src/actions/notification.ts index 6b45948..8189336 100644 --- a/src/actions/notification.ts +++ b/src/actions/notification.ts @@ -20,11 +20,11 @@ const PERMITTED_ATTRIBUTES = [ const createNotification = (streamElement: StreamElement) => { const title = streamElement.getAttribute("title") || "" - const options = Array.from(streamElement.attributes) + const attributes = Array.from(streamElement.attributes) .filter((attribute) => PERMITTED_ATTRIBUTES.includes(attribute.name)) - .reduce((acc, attribute) => { - return { ...acc, [camelize(attribute.name)]: typecast(attribute.value) } - }, {}) + .map((attribute) => [camelize(attribute.name), typecast(attribute.value)) + + const options = Object.fromEntries(attributes) new Notification(title, options) } From 317a71a1b9e6bd04c87cde279938598994a19c20 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 11 Feb 2023 11:57:41 +0100 Subject: [PATCH 7/8] Fix syntax error --- src/actions/notification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/notification.ts b/src/actions/notification.ts index 8189336..b1d245c 100644 --- a/src/actions/notification.ts +++ b/src/actions/notification.ts @@ -22,7 +22,7 @@ const createNotification = (streamElement: StreamElement) => { const attributes = Array.from(streamElement.attributes) .filter((attribute) => PERMITTED_ATTRIBUTES.includes(attribute.name)) - .map((attribute) => [camelize(attribute.name), typecast(attribute.value)) + .map((attribute) => [camelize(attribute.name), typecast(attribute.value)]) const options = Object.fromEntries(attributes) From 3a2bbf0ce35a1b97c5a38accc84a31b5b8631e01 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 11 Feb 2023 12:08:56 +0100 Subject: [PATCH 8/8] use `es2019` lib --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index c1eaa6d..8665a36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["dom", "es2015", "scripthost"], + "lib": ["dom", "es2019", "scripthost"], "module": "es2015", "moduleResolution": "node", "noUnusedLocals": true,