From 5da8f8158d22b3967e80a3b0bb4270477cc0c6a8 Mon Sep 17 00:00:00 2001 From: Falk Date: Tue, 1 Feb 2022 12:22:38 +0100 Subject: [PATCH 1/5] feat: only proceed confirmed deletions in EditForm --- addon/components/edit-form.js | 3 ++ addon/decorators/confirm-task.js | 87 ++++++++++++++++++++++++++++++++ translations/en.yaml | 4 ++ 3 files changed, 94 insertions(+) create mode 100644 addon/decorators/confirm-task.js diff --git a/addon/components/edit-form.js b/addon/components/edit-form.js index d48b5ae0..92b57493 100644 --- a/addon/components/edit-form.js +++ b/addon/components/edit-form.js @@ -3,6 +3,8 @@ import { inject as service } from "@ember/service"; import Component from "@glimmer/component"; import { task } from "ember-concurrency"; +import { confirmTask } from "../decorators/confirm-task"; + import { handleTaskErrors } from "ember-emeis/-private/decorators"; export default class EditFormComponent extends Component { @@ -76,6 +78,7 @@ export default class EditFormComponent extends Component { } @task + @confirmTask({ message: "emeis.form.confirmEntryDelete" }) @handleTaskErrors({ errorMessage: "emeis.form.delete-error" }) *delete() { yield this.args.model.destroyRecord(); diff --git a/addon/decorators/confirm-task.js b/addon/decorators/confirm-task.js new file mode 100644 index 00000000..d218ae06 --- /dev/null +++ b/addon/decorators/confirm-task.js @@ -0,0 +1,87 @@ +import { assert } from "@ember/debug"; +import UIkit from "uikit"; + +/** + * This decorator makes heavy use of the UIKit modal. If anything breaks in future releases, it could be + * caused by the use of non-public API in this decorator. + * + * For further details have a look at: https://github.com/uikit/uikit/blob/develop/src/js/core/modal.js + */ + +async function confirm(text, options) { + try { + await UIkit.modal.confirm(text, options); + return true; + } catch (error) { + return false; + } +} + +function validate(args) { + if (args.length === 1) { + assert( + "You should pass the confirm-task options as an object looking like this: { message: 'emeis.form.deleteMessage', cancel: 'emeis.form.back' } ", + typeof args[0] === "object" + ); + assert( + "The confirm-task option object needs at least a message property.", + args[0].message + ); + return true; + } + return false; +} + +function validateIntl(context) { + assert( + "Inject the `intl` service into your component to properly translate the modal dialog.", + context.intl + ); +} + +function translateOptions(context, options) { + const translated = {}; + for (const key in options) { + translated[key] = context.intl.t(options[key]); + } + return { + labels: { + // set some defaults + message: context.intl.t("emeis.form.confirmText"), + ok: context.intl.t("emeis.form.ok"), + cancel: context.intl.t("emeis.form.cancel"), + // add the override with the transmitted options + ...translated, + }, + }; +} + +// make sure that decorator can be called with or without arguments +const makeFlexibleDecorator = (decorateFn, args) => { + if (args.length === 3 && !args[0].message) { + // We can assume that the decorator was called without options + return decorateFn(...args); + } + + return decorateFn; +}; + +export function confirmTask(...decoratorArgs) { + const options = validate(decoratorArgs) ? decoratorArgs[0] : {}; + + function decorate(target, property, desc) { + const gen = desc.value; + + desc.value = function* (...args) { + validateIntl(this); + const translatedOptions = translateOptions(this, options); + if ( + !(yield confirm(translatedOptions.labels.message, translatedOptions)) + ) { + return; + } + return yield* gen.apply(this, args); + }; + } + return makeFlexibleDecorator(decorate, decoratorArgs); +} diff --git a/translations/en.yaml b/translations/en.yaml index 15326dc7..dc22ab78 100644 --- a/translations/en.yaml +++ b/translations/en.yaml @@ -67,6 +67,10 @@ emeis: save: "Save" back: "Back" delete: "Delete" + ok: "OK" + cancel: "Cancel" + confirmText: "Are you sure you want to proceed with this action?" + confirmEntryDelete: "Are you sure you want to delete this entry?" save-success: "Saved successfully" delete-success: "Deleted successfully" save-error: "A problem occurred while saving. Please try again." From 295aa726cea3d67b7614c116a22376c86b096c10 Mon Sep 17 00:00:00 2001 From: Falk Date: Tue, 1 Feb 2022 13:05:11 +0100 Subject: [PATCH 2/5] feat: add edit and delete actions to userlist --- addon/controllers/users/index.js | 14 ++++++++++++++ addon/templates/users/index.hbs | 11 +++++++++++ translations/en.yaml | 2 ++ 3 files changed, 27 insertions(+) diff --git a/addon/controllers/users/index.js b/addon/controllers/users/index.js index f3097091..d780e076 100644 --- a/addon/controllers/users/index.js +++ b/addon/controllers/users/index.js @@ -1,9 +1,15 @@ import { inject as service } from "@ember/service"; +import { task } from "ember-concurrency"; + +import { confirmTask } from "../../decorators/confirm-task"; import PaginationController from "ember-emeis/-private/controllers/pagination"; +import { handleTaskErrors } from "ember-emeis/-private/decorators"; export default class UsersIndexController extends PaginationController { @service emeisOptions; + @service notification; + @service intl; get emailAsUsername() { return this.emeisOptions.emailAsUsername; @@ -16,4 +22,12 @@ export default class UsersIndexController extends PaginationController { get linkToScope() { return this.emeisOptions.navigationEntries?.includes("scopes"); } + + @task + @confirmTask({ message: "emeis.form.confirmUserDelete" }) + @handleTaskErrors({ errorMessage: "emeis.form.delete-error" }) + *delete(model) { + yield model.destroyRecord(); + this.notification.success(this.intl.t("emeis.form.delete-success")); + } } diff --git a/addon/templates/users/index.hbs b/addon/templates/users/index.hbs index d6c459cc..c14c3a85 100644 --- a/addon/templates/users/index.hbs +++ b/addon/templates/users/index.hbs @@ -30,6 +30,9 @@ {{t "emeis.scopes.title"}} + + {{t "emeis.actions"}} + @@ -89,6 +92,14 @@ {{/each}} + + + + + + + + {{/let}} diff --git a/translations/en.yaml b/translations/en.yaml index dc22ab78..09f69dce 100644 --- a/translations/en.yaml +++ b/translations/en.yaml @@ -4,6 +4,7 @@ emeis: empty: "No entries found..." loading: "Loading..." general-error: "A problem occured. Please try again." + actions: "Actions" scopes: title: "Scopes" @@ -71,6 +72,7 @@ emeis: cancel: "Cancel" confirmText: "Are you sure you want to proceed with this action?" confirmEntryDelete: "Are you sure you want to delete this entry?" + confirmUserDelete: "Are you sure you want to delete this user?" save-success: "Saved successfully" delete-success: "Deleted successfully" save-error: "A problem occurred while saving. Please try again." From f50f2b24c8bc4af0ac6e6994fe7eec4902b26f24 Mon Sep 17 00:00:00 2001 From: Falk Date: Tue, 1 Feb 2022 14:55:41 +0100 Subject: [PATCH 3/5] test: add coverage for confirm-task decorator --- addon/decorators/confirm-task.js | 20 +++++--- tests/unit/decorators/confirm-task-test.js | 53 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/unit/decorators/confirm-task-test.js diff --git a/addon/decorators/confirm-task.js b/addon/decorators/confirm-task.js index d218ae06..59204b3e 100644 --- a/addon/decorators/confirm-task.js +++ b/addon/decorators/confirm-task.js @@ -8,6 +8,8 @@ import UIkit from "uikit"; * For further details have a look at: https://github.com/uikit/uikit/blob/develop/src/js/core/modal.js */ +const LABELS = ["message", "ok", "cancel"]; + async function confirm(text, options) { try { await UIkit.modal.confirm(text, options); @@ -23,10 +25,6 @@ function validate(args) { "You should pass the confirm-task options as an object looking like this: { message: 'emeis.form.deleteMessage', cancel: 'emeis.form.back' } ", typeof args[0] === "object" ); - assert( - "The confirm-task option object needs at least a message property.", - args[0].message - ); return true; } return false; @@ -42,7 +40,9 @@ function validateIntl(context) { function translateOptions(context, options) { const translated = {}; for (const key in options) { - translated[key] = context.intl.t(options[key]); + if (LABELS.includes(key)) { + translated[key] = context.intl.t(options[key]); + } } return { labels: { @@ -75,8 +75,16 @@ export function confirmTask(...decoratorArgs) { desc.value = function* (...args) { validateIntl(this); const translatedOptions = translateOptions(this, options); + // append other options which are not confirm-labels to the modal object + // that way you can modify the modal with further options like "container" + const filteredOptions = Object.fromEntries( + Object.entries(options).filter(([key]) => !LABELS.includes(key)) + ); if ( - !(yield confirm(translatedOptions.labels.message, translatedOptions)) + !(yield confirm(translatedOptions.labels.message, { + ...translatedOptions, + ...filteredOptions, + })) ) { return; } diff --git a/tests/unit/decorators/confirm-task-test.js b/tests/unit/decorators/confirm-task-test.js new file mode 100644 index 00000000..6b1ac603 --- /dev/null +++ b/tests/unit/decorators/confirm-task-test.js @@ -0,0 +1,53 @@ +import { click, waitFor } from "@ember/test-helpers"; +import { task } from "ember-concurrency"; +import { setupTest } from "ember-qunit"; +import { module, test } from "qunit"; + +import { confirmTask } from "ember-emeis/decorators/confirm-task"; + +const DUMMY_ARGUMENT = { test: "ok" }; + +module("Unit | decorators | confirm-task", function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function (assert) { + this.TestStub = class TestStub { + intl = { + t: (s) => { + return s; + }, + }; + + @task + @confirmTask({ container: "#ember-testing" }) + *deleteSomething(someArgument) { + assert.step("delete"); + return yield someArgument; + } + }; + }); + + test("it triggers the action on confirm", async function (assert) { + assert.expect(3); + const instance = new this.TestStub(); + + const result = instance.deleteSomething.perform(DUMMY_ARGUMENT); + + await waitFor(".uk-modal.uk-open"); + await click(".uk-button-primary"); + + assert.deepEqual(result.args[0], DUMMY_ARGUMENT); + assert.verifySteps(["delete"]); + }); + + test("it does not trigger the action on cancel", async function (assert) { + assert.expect(1); + const instance = new this.TestStub(); + instance.deleteSomething.perform(); + + await waitFor(".uk-modal.uk-open"); + await click(".uk-modal-close"); + + assert.verifySteps([]); + }); +}); From bc6c8d1113dc82387d18bf8b7f578baf698f81c9 Mon Sep 17 00:00:00 2001 From: Falk Date: Tue, 1 Feb 2022 15:04:51 +0100 Subject: [PATCH 4/5] chore: add missing translations --- translations/de.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/translations/de.yaml b/translations/de.yaml index 0ce5bddf..d4f16e19 100644 --- a/translations/de.yaml +++ b/translations/de.yaml @@ -4,6 +4,7 @@ emeis: empty: "Keine Einträge gefunden..." loading: "Laden..." general-error: "Ein Problem ist aufgetretten. Bitte versuchen Sie es erneut." + actions: "Aktionen" scopes: title: "Berechtigungsbereiche" @@ -66,10 +67,15 @@ emeis: form: save: "Speichern" back: "Zurück" - save-success: "Erfolgreich gespeichert." - save-error: "Während dem Speichern ist ein Fehler aufgetretten. Bitte versuchen Sie es erneut." delete: "Löschen" + ok: "OK" + cancel: "Abbrechen" + confirmText: "Sind Sie sich sicher, dass Sie diese Aktion fortfahren wollen?" + confirmEntryDelete: "Sind Sie sich sicher, dass Sie diesen Eintrag löschen wollen?" + confirmUserDelete: "Sind Sie sich sicher, dass Sie diesen Benutzer löschen wollen?" + save-success: "Erfolgreich gespeichert." delete-success: "Erfolgreich gelöscht." + save-error: "Während dem Speichern ist ein Fehler aufgetretten. Bitte versuchen Sie es erneut." delete-error: "Beim löschen ist ein Problem aufgetretten. Falls der Eintrag noch existiert, versuchen Sie es erneut." phone-hint: "Buchstaben sind in Telefonnummern nicht erlaubt." slug-hint: "Es sind nur Buchstaben, Nummern und Bindestriche erlaubt." From cc51c0a3bf0f8e073abc77e5feb26646adc9e01a Mon Sep 17 00:00:00 2001 From: Falk Date: Tue, 1 Feb 2022 16:19:18 +0100 Subject: [PATCH 5/5] test: fix tests where confirm-task decorator in passive use --- addon/decorators/confirm-task.js | 33 ++++++++++++++----- addon/templates/users/index.hbs | 3 +- app/styles/ember-emeis.scss | 4 +++ tests/acceptance/permissions-test.js | 4 +++ tests/acceptance/roles-test.js | 3 ++ tests/acceptance/scopes-test.js | 3 ++ .../integration/components/edit-form-test.js | 5 ++- 7 files changed, 44 insertions(+), 11 deletions(-) diff --git a/addon/decorators/confirm-task.js b/addon/decorators/confirm-task.js index 59204b3e..4b4fd711 100644 --- a/addon/decorators/confirm-task.js +++ b/addon/decorators/confirm-task.js @@ -1,6 +1,8 @@ import { assert } from "@ember/debug"; import UIkit from "uikit"; +import ENV from "ember-emeis/config/environment"; + /** * This decorator makes heavy use of the UIKit modal. If anything breaks in future releases, it could be * caused by the use of non-public API in this decorator. @@ -19,15 +21,15 @@ async function confirm(text, options) { } } -function validate(args) { +function initOptions(args) { if (args.length === 1) { assert( "You should pass the confirm-task options as an object looking like this: { message: 'emeis.form.deleteMessage', cancel: 'emeis.form.back' } ", typeof args[0] === "object" ); - return true; + return args[0]; } - return false; + return {}; } function validateIntl(context) { @@ -40,7 +42,7 @@ function validateIntl(context) { function translateOptions(context, options) { const translated = {}; for (const key in options) { - if (LABELS.includes(key)) { + if (LABELS.includes(key) && context.intl.exists(options[key])) { translated[key] = context.intl.t(options[key]); } } @@ -56,6 +58,17 @@ function translateOptions(context, options) { }; } +function filterOptions(options) { + const filteredOptions = Object.fromEntries( + Object.entries(options).filter(([key]) => !LABELS.includes(key)) + ); + + if (ENV.environment === "test") { + filteredOptions.container = "#ember-testing"; + } + return filteredOptions; +} + // make sure that decorator can be called with or without arguments const makeFlexibleDecorator = (decorateFn, args) => { if (args.length === 3 && !args[0].message) { @@ -67,19 +80,23 @@ const makeFlexibleDecorator = (decorateFn, args) => { }; export function confirmTask(...decoratorArgs) { - const options = validate(decoratorArgs) ? decoratorArgs[0] : {}; + const options = initOptions(decoratorArgs); function decorate(target, property, desc) { const gen = desc.value; desc.value = function* (...args) { + const event = args.find((arg) => arg instanceof Event); + + if (event) { + event.preventDefault(); + } + validateIntl(this); const translatedOptions = translateOptions(this, options); // append other options which are not confirm-labels to the modal object // that way you can modify the modal with further options like "container" - const filteredOptions = Object.fromEntries( - Object.entries(options).filter(([key]) => !LABELS.includes(key)) - ); + const filteredOptions = filterOptions(options); if ( !(yield confirm(translatedOptions.labels.message, { ...translatedOptions, diff --git a/addon/templates/users/index.hbs b/addon/templates/users/index.hbs index c14c3a85..b3649a58 100644 --- a/addon/templates/users/index.hbs +++ b/addon/templates/users/index.hbs @@ -31,7 +31,6 @@ {{t "emeis.scopes.title"}} - {{t "emeis.actions"}} @@ -92,7 +91,7 @@ {{/each}} - + diff --git a/app/styles/ember-emeis.scss b/app/styles/ember-emeis.scss index 3c0df837..8940e035 100644 --- a/app/styles/ember-emeis.scss +++ b/app/styles/ember-emeis.scss @@ -67,6 +67,10 @@ div.uk-grid-divider-fix.uk-grid-stack > .uk-grid-margin::before { } } +.uk-table td.small-padding-right { + padding-right: 1.75em; +} + .sortable-th { cursor: pointer; diff --git a/tests/acceptance/permissions-test.js b/tests/acceptance/permissions-test.js index bd20e10a..43718634 100644 --- a/tests/acceptance/permissions-test.js +++ b/tests/acceptance/permissions-test.js @@ -3,6 +3,7 @@ import { currentURL, fillIn, click, + waitFor, waitUntil, settled, } from "@ember/test-helpers"; @@ -151,6 +152,9 @@ module("Acceptance | permissions", function (hooks) { }); await click("[data-test-delete]"); + await waitFor(".uk-modal.uk-open"); + await click(".uk-modal .uk-button-primary"); + // For some reason the await click is not actually waiting for the delete task to finish. // Probably some runloop issue. await waitUntil(() => currentURL() !== `/permissions/${permission.id}`); diff --git a/tests/acceptance/roles-test.js b/tests/acceptance/roles-test.js index 6e6117b0..a0a7b67b 100644 --- a/tests/acceptance/roles-test.js +++ b/tests/acceptance/roles-test.js @@ -4,6 +4,7 @@ import { click, fillIn, findAll, + waitFor, waitUntil, settled, } from "@ember/test-helpers"; @@ -131,6 +132,8 @@ module("Acceptance | roles", function (hooks) { assert.strictEqual(role.id, request.params.id); }); await click("[data-test-delete]"); + await waitFor(".uk-modal.uk-open"); + await click(".uk-modal .uk-button-primary"); // For some reason the await click is not actually waiting for the delete task to finish. // Probably some runloop issue. diff --git a/tests/acceptance/scopes-test.js b/tests/acceptance/scopes-test.js index 513de558..61a96b7e 100644 --- a/tests/acceptance/scopes-test.js +++ b/tests/acceptance/scopes-test.js @@ -3,6 +3,7 @@ import { currentURL, fillIn, click, + waitFor, waitUntil, settled, } from "@ember/test-helpers"; @@ -140,6 +141,8 @@ module("Acceptance | scopes", function (hooks) { assert.strictEqual(scope.id, request.params.id); }); await click("[data-test-delete]"); + await waitFor(".uk-modal.uk-open"); + await click(".uk-modal .uk-button-primary"); // For some reason the await click is not actually waiting for the delete task to finish. // Probably some runloop issue. diff --git a/tests/integration/components/edit-form-test.js b/tests/integration/components/edit-form-test.js index e944d9a1..964f09b4 100644 --- a/tests/integration/components/edit-form-test.js +++ b/tests/integration/components/edit-form-test.js @@ -1,5 +1,5 @@ import Service from "@ember/service"; -import { render, click } from "@ember/test-helpers"; +import { render, click, waitFor } from "@ember/test-helpers"; import { hbs } from "ember-cli-htmlbars"; import { setupIntl } from "ember-intl/test-support"; import { setupRenderingTest } from "ember-qunit"; @@ -89,6 +89,9 @@ module("Integration | Component | edit-form", function (hooks) { await render(hbs``); await click("[data-test-delete]"); + await waitFor(".uk-modal.uk-open"); + await click(".uk-modal .uk-button-primary"); + assert.verifySteps(["destroyRecord"]); });