Skip to content

Commit

Permalink
feat(custom-buttons): configurable custom buttons (projectcaluma#350)
Browse files Browse the repository at this point in the history
* feat(custom-buttons): configurable custom buttons

* fix(emeis-options): make use of intl translation keys optional

* feat(emeis-options): allow multiple custom buttons per view

Co-authored-by: Christian Zosel <[email protected]>
  • Loading branch information
derrabauke and czosel committed Dec 21, 2021
1 parent 4af09c0 commit 8ffdb31
Show file tree
Hide file tree
Showing 13 changed files with 180 additions and 37 deletions.
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ The frontend for the [emeis](https://github.com/projectcaluma/emeis) user manage

## Compatibility

* Ember.js v3.20 or above
* Ember CLI v3.20 or above
* Node.js v10 or above
- Ember.js v3.20 or above
- Ember CLI v3.20 or above
- Node.js v10 or above

## Installation

Expand Down Expand Up @@ -88,20 +88,35 @@ export default class EmeisOptionsService extends Service {
// show only a subset of the main navigation entries
navigationEntries = ["users", "scopes"];

// On each model edit view (e.g. users) you can define a list of custom buttons. Each button needs a label and a callback function. Optionally you can highlight the button with the 'type' attribute.
customButtons = {
users: [
{
label: "My Button", // this could also be an ember-intl translation key
callback: () => window.alert("test"),
type: "danger" // leave blank or choose between primary, danger
},
{
label: "A second Button",
callback: () => window.alert("test"),
}
]
};

// define custom fields for a given context (user, scope, role or permission)
metaFields = {
user: [],
scope: [
{
slug: "test-input",
label: "translation.key",
label: "My Input", // this could also be an ember-intl translation key
type: "text",
visible: true,
readOnly: false
},
{
slug: "test-input-2",
label: "translation.key-2",
label: "some.translation.key",
type: "choice",
visible: () => true,
readOnly: false
Expand All @@ -111,25 +126,34 @@ export default class EmeisOptionsService extends Service {
}
```
*Watch out* - the translation key has to be present in your local translation files.
_Watch out_ - the translation key has to be present in your local translation files.
There are special options available for `type` and `visible` properties.
#### **type** - meta field
Defines the type of the output component and can either be a *text* or a *choice*.
Defines the type of the output component and can either be a _text_ or a _choice_.
#### **visible** & **readOnly** meta field
Accepts a boolean value for static visibility or a function which evaluates to a boolean value. Submitted functions will evaluate live while rendering.
The evaluation function will receive the current model as argument. For instance if you are on the scope route, you will receive the [scope model](addon/models/scope.js) as first argument. Same for [user](addon/models/user.js) | [role](addon/models/role.js) | [permission](addon/models/permission.js)
So the function signature looks like this for `visible` and `readOnly`.
```ts
type visible = (model:scope|user|role|permission) => boolean;
type visible = (model: scope | user | role | permission) => boolean;
```
And an actual implementation example, which makes use of the `mode.name` property:
And an actual implementation example, which makes use of the `model.name` property:
```js
visible: (model) => model.name === "test-scope"
{
// ...
visible: (model) => model.name === "test-scope",
// ...
}
```
For a complete `emeis-options` configuration open the [test config](tests/dummy/app/services/emeis-options.js).
Expand All @@ -140,7 +164,7 @@ If you need to customize your store service passed to emeis, use:
`ember g emeis-store <your_name>`
This will generate a store service and an adapter for you. In those two files
you can then configure custom api endpoints or hosts and/or custom
you can then configure custom api endpoints or hosts and/or custom
authentication.
## Contributing
Expand Down
14 changes: 14 additions & 0 deletions addon/components/edit-form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
>
{{yield}}

<div class="uk-flex uk-flex-left uk-margin">
{{#if this.customButtons}}
{{#each this.customButtons as |button|}}
<UkButton
@type="button"
@label={{optional-translate button.label}}
class="uk-margin-right {{if button.type (concat 'uk-button-' button.type) ''}}"
{{on "click" (fn this.customAction button)}}
data-test-custom-button
/>
{{/each}}
{{/if}}
</div>

<div class="uk-flex uk-flex-right uk-margin">
{{! without quotes on the @route the LinkTo component tries to set the property listViewRouteName which has no setter}}
<LinkTo
Expand Down
20 changes: 20 additions & 0 deletions addon/components/edit-form.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { task } from "ember-concurrency";
Expand All @@ -8,6 +9,7 @@ export default class EditFormComponent extends Component {
@service intl;
@service notification;
@service router;
@service emeisOptions;

get parentRouteName() {
return this.router.currentRoute.parent.name;
Expand Down Expand Up @@ -38,6 +40,24 @@ export default class EditFormComponent extends Component {
);
}

get modelName() {
return this.relativeParentRouteName.split(".")[0];
}

get customButtons() {
return this.emeisOptions.customButtons?.[this.modelName];
}

@action
customAction(button) {
if (typeof button.callback !== "function") {
this.notification.danger(
this.intl.t("emeis.form.custom-button-action-error")
);
}
button.callback();
}

@task
@handleModelErrors({ errorMessage: "emeis.form.save-error" })
*save(event) {
Expand Down
4 changes: 2 additions & 2 deletions addon/components/meta-fields.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{{#each @fields as |field|}}
{{#if (eval-meta field.visible @model)}}
<EditForm::Element @label={{t field.label}}>
<EditForm::Element @label={{optional-translate field.label}}>
{{#if (eq field.type "choice")}}
<PowerSelect
@disabled={{eval-meta field.readOnly @model}}
Expand All @@ -11,7 +11,7 @@
@allowClear={{true}}
as |option|
>
{{t option.label}}
{{optional-translate option.label}}
</PowerSelect>
{{/if}}
{{#if (eq field.type "text")}}
Expand Down
42 changes: 21 additions & 21 deletions addon/controllers/users/edit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,6 @@ export default class UsersEditIndexController extends Controller {
return this.emeisOptions.metaFields?.user;
}

@action
updateModel(model, formElements) {
model.firstName = formElements.firstName.value;
model.lastName = formElements.lastName.value;
model.email = formElements.email.value;
model.isActive = formElements.isActive.checked;

// additional fields might not be present
model.phone = formElements.phone?.value;
model.language = formElements.language?.selectedOptions[0].value;
model.address = formElements.address?.value;
model.city = formElements.city?.value;
model.zip = formElements.zip?.value;

model.username = this.emailAsUsername
? formElements.email.value
: formElements.username.value;

return model;
}

get emailAsUsername() {
return this.emeisOptions.emailAsUsername;
}
Expand All @@ -53,4 +32,25 @@ export default class UsersEditIndexController extends Controller {
.filter(([, value]) => value === "required")
.map(([key]) => key);
}

@action
updateModel(model, formElements) {
model.firstName = formElements.firstName.value;
model.lastName = formElements.lastName.value;
model.email = formElements.email.value;
model.isActive = formElements.isActive.checked;

// additional fields might not be present
model.phone = formElements.phone?.value;
model.language = formElements.language?.selectedOptions[0].value;
model.address = formElements.address?.value;
model.city = formElements.city?.value;
model.zip = formElements.zip?.value;

model.username = this.emailAsUsername
? formElements.email.value
: formElements.username.value;

return model;
}
}
10 changes: 10 additions & 0 deletions addon/helpers/optional-translate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Helper from "@ember/component/helper";
import { inject as service } from "@ember/service";

export default class OptionalTranslate extends Helper {
@service intl;

compute([string]) {
return this.intl.exists(string) ? this.intl.t(string) : string;
}
}
1 change: 1 addition & 0 deletions addon/templates/users/edit/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,5 @@
checked={{@model.isActive}}
/>
</EditForm::Element>

</EditForm>
1 change: 1 addition & 0 deletions app/helpers/optional-translate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "ember-emeis/helpers/optional-translate";
17 changes: 14 additions & 3 deletions tests/dummy/app/services/emeis-options.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import Service, { inject as service } from "@ember/service";
import Service from "@ember/service";

export default class EmeisOptionsService extends Service {
@service intl;

emailAsUsername = false;
pageSize = 10;
// additionalUserFields = {
// phone: "optional",
// language: "optional",
// };
// navigationEntries = ["users", "scopes"];
customButtons = {
users: [
{
label: "This is a custom button",
callback: () => console.warn("test"),
type: "primary",
},
{
label: "Second Button",
callback: () => console.warn("test"),
},
],
};
metaFields = {
user: [
{
Expand Down
39 changes: 39 additions & 0 deletions tests/integration/components/edit-form-test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import Service from "@ember/service";
import { render, click } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { setupIntl } from "ember-intl/test-support";
import { setupRenderingTest } from "ember-qunit";
import { module, test } from "qunit";

class EmeisOptionsStub extends Service {
customButtons = {
users: [
{
label: "This is a custom button",
callback: () => {
document
.querySelector("[data-test-custom-button]")
.setAttribute("data-test-action-triggered", true);
},
type: "danger",
},
],
};
}

module("Integration | Component | edit-form", function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
Expand All @@ -27,6 +44,7 @@ module("Integration | Component | edit-form", function (hooks) {
instantiate: false,
});
this.owner.register("router:main", this.router, { instantiate: false });
this.owner.register("service:emeis-options", EmeisOptionsStub);
});

hooks.afterEach(function () {
Expand Down Expand Up @@ -105,4 +123,25 @@ module("Integration | Component | edit-form", function (hooks) {
await click("[data-test-save]");
assert.verifySteps(["updateModel", "save", "replaceWith"]);
});

test("custom action", async function (assert) {
assert.expect(4);
this.router.currentRoute.parent.name = "ember-emeis.users.edit";

await render(hbs`
<EditForm></EditForm>
`);

assert.dom("[data-test-custom-button]").exists();
assert.dom("[data-test-custom-button].uk-button-danger").exists();
assert
.dom("[data-test-custom-button]")
.hasNoAttribute("data-test-action-triggered");

await click("[data-test-custom-button]");

assert
.dom("[data-test-custom-button]")
.hasAttribute("data-test-action-triggered");
});
});
21 changes: 21 additions & 0 deletions tests/integration/helpers/optional-translate-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { setupIntl } from "ember-intl/test-support";
import { setupRenderingTest } from "ember-qunit";
import { module, test } from "qunit";

module("Integration | Helper | optional-translate", function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, { foo: "translation of foo" });

test("it translates if the translation exists", async function (assert) {
await render(hbs`{{optional-translate "foo"}}`);

assert.dom(this.element).hasText("translation of foo");
});
test("it falls back to the translation key otherwise", async function (assert) {
await render(hbs`{{optional-translate "baz"}}`);

assert.dom(this.element).hasText("baz");
});
});
1 change: 1 addition & 0 deletions translations/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ emeis:
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."
custom-button-action-error: "Die konfigurierte Custom Button Action ist keine valide Funktion."

acl-table:
title: "Berechtigung"
Expand Down
1 change: 1 addition & 0 deletions translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ emeis:
delete-error: "A problem occurred while deleting. Please check if the entry still exists and try again."
phone-hint: "Letters are not allowed in phone numbers."
slug-hint: "Only letters, numbers and hyphens are allowed."
custom-button-action-error: "The submitted custom button action is not a valid function."

acl-table:
title: "ACL"
Expand Down

0 comments on commit 8ffdb31

Please sign in to comment.