diff --git a/addon/components/array-input.hbs b/addon/components/array-input.hbs index ff9b0eb..de919ff 100644 --- a/addon/components/array-input.hbs +++ b/addon/components/array-input.hbs @@ -1,8 +1,16 @@ -
+
{{yield this.data}}
-
@@ -14,13 +22,19 @@ @value={{datum}} aria-label="Data Input" placeholder={{@placeholder}} + disabled={{this.disabled}} + class="form-input w-full flex-1 border-none shadow-none rounded-none pr-24" {{on "change" (fn this.onChange index)}} {{on "paste" (fn this.onPaste index)}} - class="form-input w-full flex-1 border-none shadow-none rounded-none pr-24" {{on "keyup" (fn this.inputDatum index)}} />
- +
{{/each}} diff --git a/addon/components/array-input.js b/addon/components/array-input.js index 78bf092..cc6ec8e 100644 --- a/addon/components/array-input.js +++ b/addon/components/array-input.js @@ -4,11 +4,13 @@ import { action } from '@ember/object'; export default class ArrayInputComponent extends Component { @tracked data = []; + @tracked disabled = false; - constructor() { + constructor(owner, { data = [], disabled = false }) { super(...arguments); - this.data = this.args.data ?? []; + this.data = data; + this.disabled = disabled; } @action onChange(index, event) { diff --git a/addon/components/button.js b/addon/components/button.js index 9e04667..d3f2abe 100644 --- a/addon/components/button.js +++ b/addon/components/button.js @@ -48,6 +48,13 @@ export default class ButtonComponent extends Component { return icon && !isLoading; } + /** + * The permission required. + * + * @memberof ButtonComponent + */ + @tracked permissionRequired; + /** * If the button is disabled by permissions. * diff --git a/addon/components/checkbox.hbs b/addon/components/checkbox.hbs index d885386..92b180d 100644 --- a/addon/components/checkbox.hbs +++ b/addon/components/checkbox.hbs @@ -1,32 +1,40 @@ -
-
- - {{#if @helpText}} - - - - {{/if}} -
- {{#if (has-block)}} -
- -
- {{else if @label}} -
- +{{#if this.visible}} +
+
+ + {{#if (has-block)}} +
+ +
+ {{else if @label}} +
+ +
+ {{/if}} + {{#if this.disabledByPermission}} + + + + {{else if @helpText}} + + + + {{/if}}
- {{/if}} -
\ No newline at end of file + +
+{{/if}} \ No newline at end of file diff --git a/addon/components/checkbox.js b/addon/components/checkbox.js index a0f3b57..42de8b5 100644 --- a/addon/components/checkbox.js +++ b/addon/components/checkbox.js @@ -1,9 +1,12 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { computed, action } from '@ember/object'; +import { inject as service } from '@ember/service'; import { guidFor } from '@ember/object/internals'; export default class CheckboxComponent extends Component { + @service abilities; + /** * Generates a unique ID for this checkbox instance * @@ -33,6 +36,44 @@ export default class CheckboxComponent extends Component { */ @tracked colorClass = 'text-sky-500'; + /** + * The permission required. + * + * @memberof CheckboxComponent + */ + @tracked permissionRequired; + + /** + * If the button is disabled by permissions. + * + * @memberof CheckboxComponent + */ + @tracked disabledByPermission = false; + + /** + * Determines the visibility of the button + * + * @memberof CheckboxComponent + */ + @tracked visible = true; + + /** + * Creates an instance of ButtonComponent. + * @param {*} owner + * @param {*} { permission = null } + * @memberof ButtonComponent + */ + constructor(owner, { value = false, permission = null, disabled = false, visible = true }) { + super(...arguments); + this.checked = value; + this.permissionRequired = permission; + this.visible = visible; + this.disabled = disabled; + if (!disabled) { + this.disabled = this.disabledByPermission = permission && this.abilities.cannot(permission); + } + } + /** * Toggles the checkbox and sends up an action * diff --git a/addon/components/layout/header.hbs b/addon/components/layout/header.hbs index 214e8d0..7c41ba5 100644 --- a/addon/components/layout/header.hbs +++ b/addon/components/layout/header.hbs @@ -8,7 +8,7 @@ {{/if}} {{#unless (media "isMobile")}}
- +
- {{first-char @user.company_name}} + {{first-char this.currentUser.companyName}}
-
{{@user.company_name}}
+
{{this.currentUser.companyName}}
- +
{{@user.name}}
diff --git a/addon/components/layout/header.js b/addon/components/layout/header.js index 852c14a..4e0dbaf 100644 --- a/addon/components/layout/header.js +++ b/addon/components/layout/header.js @@ -1,8 +1,9 @@ import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { computed, action } from '@ember/object'; -import { alias } from '@ember/object/computed'; -import { isArray } from '@ember/array'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import config from 'ember-get-config'; /** * Layout header component. @@ -16,78 +17,136 @@ export default class LayoutHeaderComponent extends Component { @service router; @service hostRouter; @service universe; + @service currentUser; + @service abilities; + @service fetch; + @tracked company; + @tracked menuItems = []; + @tracked organizationMenuItems = []; + @tracked userMenuItems = []; + @tracked extensions = []; - @alias('args.user') user; - - @computed('store', 'user.company_uuid') get company() { - return this.store.peekRecord('company', this.user.company_uuid); + constructor(owner, { menuItems = [], organizationMenuItems = [], userMenuItems = [] }) { + super(...arguments); + this.extensions = getOwner(this).application.extensions ?? []; + this.company = this.currentUser.getCompany(); + this.menuItems = this.mergeMenuItems(menuItems); + this.organizationMenuItems = this.mergeOrganizationMenuItems(organizationMenuItems); + this.userMenuItems = this.mergeUserMenuItems(userMenuItems); } - @computed('args.{organizationNavigationItems,organizations.length}', 'organizations.@each.id', 'universe.organizationMenuItems', 'user.{company_name,email}') - get organizationNavigationItems() { - const universeOrganizationItems = this.universe.organizationMenuItems; + mergeMenuItems(menuItems = []) { + const headerMenuItems = this.universe.headerMenuItems; + const visibleMenuItems = []; + for (let i = 0; i < headerMenuItems.length; i++) { + const menuItem = headerMenuItems[i]; + if (this.abilities.can(`${menuItem.slug} see extension`)) { + visibleMenuItems.pushObject(menuItem); + } + } - if (isArray(this.args.organizationNavigationItems)) { - const items = this.args.organizationNavigationItems; + // Merge additionals + visibleMenuItems.pushObjects(menuItems); - if (universeOrganizationItems) { - for (let i = 0; i < universeOrganizationItems.length; i++) { - const menuItem = universeOrganizationItems[i]; - menuItem.text = menuItem.title; - items.insertAt(menuItem.index, menuItem); - } - } + // Callback to allow mutation of menu items + if (typeof this.args.mutateMenuItems === 'function') { + this.args.mutateMenuItems(menuItems); } - const items = [ + return visibleMenuItems; + } + + mergeOrganizationMenuItems(organizationMenuItems = []) { + // Prepare menuItems + const menuItems = [ { - text: [this.user.email, this.user.company_name], + text: [this.currentUser.email, this.currentUser.companyName], class: 'flex flex-row items-center px-3 rounded-md text-gray-800 text-sm dark:text-gray-300 leading-1', wrapperClass: 'next-dd-session-user-wrapper', }, - { - seperator: true, - }, ]; - this.args.organizations?.forEach((organization) => { - items.pushObject({ + // List available organizations for session switching + const organizations = this.currentUser.organizations; + if (organizations.length) { + menuItems.pushObject({ seperator: true }); + } + for (let i = 0; i < organizations.length; i++) { + const organization = organizations.objectAt(i); + const organizationMenuItem = { href: 'javascript:;', text: organization.name, action: 'switchOrganization', params: [organization], - }); - }); + }; + + // If current organization + if (this.currentUser.companyId === organization.id) { + organizationMenuItem.icon = 'check'; + organizationMenuItem.disabled = true; + organizationMenuItem.action = undefined; + } + + menuItems.pushObject(organizationMenuItem); + } - items.pushObjects([ + // Push static menu items + const staticMenuItems = [ { seperator: true, }, { + id: 'console-home', route: 'console.home', text: 'Home', icon: 'house', }, { + id: 'organization-settings', route: 'console.settings.index', text: 'Organization settings', icon: 'gear', }, { + id: 'create-or-join-organizations', href: 'javascript:;', text: 'Create or join organizations', action: 'createOrJoinOrg', icon: 'building', }, - { + ]; + + // If registry bridge is booted add to static items + if (this.hasExtension('@fleetbase/registry-bridge')) { + staticMenuItems.pushObject({ + id: 'explore-extensions', route: 'console.extensions', text: 'Explore extensions', icon: 'puzzle-piece', - }, - ]); + }); + } + + // Push static items + menuItems.pushObjects(staticMenuItems); + + // Merge provided menu items + menuItems.pushObjects(organizationMenuItems); + + // Push the version + menuItems.pushObject({ + id: 'app-version', + route: null, + text: `v${config.version}`, + icon: 'code-branch', + iconSize: 'xs', + iconClass: 'mr-1.5', + wrapperClass: 'app-version-in-nav', + overwriteWrapperClass: true, + }); - if (this.user.get('is_admin')) { - items.pushObjects([ + // Merge admin link + if (this.currentUser.isAdmin) { + menuItems.pushObjects([ { seperator: true, }, @@ -99,7 +158,8 @@ export default class LayoutHeaderComponent extends Component { ]); } - items.pushObjects([ + // Merge logout link + menuItems.pushObjects([ { seperator: true, }, @@ -111,41 +171,37 @@ export default class LayoutHeaderComponent extends Component { }, ]); + // Get organization menu items from registry + const universeOrganizationItems = this.universe.organizationMenuItems; if (universeOrganizationItems) { - const preIndex = (this.args.organizations?.length ?? 0) + 3; + const preIndex = (organizations.length ?? 0) + (staticMenuItems.length ?? 0); for (let i = 0; i < universeOrganizationItems.length; i++) { const menuItem = universeOrganizationItems[i]; menuItem.text = menuItem.title; - items.insertAt(preIndex + menuItem.index, menuItem); + menuItems.insertAt(preIndex + menuItem.index, menuItem); } } - return items; - } - - @computed('args.{userNavigationItems,extensions}', 'universe.userMenuItems') get userNavigationItems() { - const universeUserMenuItems = this.universe.userMenuItems; - - if (isArray(this.args.userNavigationItems)) { - const items = this.args.userNavigationItems; - - if (universeUserMenuItems) { - for (let i = 0; i < universeUserMenuItems.length; i++) { - const menuItem = universeUserMenuItems[i]; - menuItem.text = menuItem.title; - items.insertAt(menuItem.index, menuItem); - } - } - - return items; + // Callback to allow mutation of menu items + if (typeof this.args.mutateOrganizationMenuItems === 'function') { + this.args.mutateOrganizationMenuItems(menuItems); } - const items = [ + return menuItems; + } + + mergeUserMenuItems(userMenuItems = []) { + // Prepare menu items + const menuItems = [ { + id: 'view-profile-user-nav-item', + wrapperClass: 'view-profile-user-nav-item', route: 'console.account.index', text: 'View Profile', }, { + id: 'show-keyboard-shortcuts-user-nav-item', + wrapperClass: 'show-keyboard-shortcuts-user-nav-item', href: 'javascript:;', text: 'Show keyboard shortcuts', disabled: true, @@ -155,38 +211,59 @@ export default class LayoutHeaderComponent extends Component { seperator: true, }, { + id: 'changelog-user-nav-item', + wrapperClass: 'changelog-user-nav-item', href: 'javascript:;', text: 'Changelog', action: 'viewChangelog', }, ]; + // Add developer menu item if booted if (this.hasExtension('@fleetbase/dev-engine')) { - items.pushObject({ + menuItems.pushObject({ + id: 'developers-user-nav-item', + wrapperClass: 'developers-user-nav-item', route: 'console.developers', text: 'Developers', }); } - items.pushObjects([ + // Add more static menu items + const supportMenuItems = [ { + id: 'discord', href: 'https://discord.gg/MJQgxHwN', target: '_discord', text: 'Join Discord Community', icon: 'arrow-up-right-from-square', }, { + id: 'support-user-nav-item', + wrapperClass: 'support-user-nav-item', href: 'https://github.com/fleetbase/fleetbase/issues', target: '_support', text: 'Help & Support', icon: 'arrow-up-right-from-square', }, { - href: 'https://fleetbase.github.io/api-reference/', - target: '_api', - text: 'API Reference', + id: 'docs-user-nav-item', + wrapperClass: 'docs-user-nav-item', + href: 'https://docs.fleetbase.io', + target: '_docs', + text: 'Documentation', icon: 'arrow-up-right-from-square', }, + ]; + + // Push support menu items + menuItems.pushObjects(supportMenuItems); + + // Push provided menu items + menuItems.pushObjects(userMenuItems); + + // Create immutable static menu items + menuItems.pushObjects([ { component: 'layout/header/dark-mode-toggle', }, @@ -200,15 +277,12 @@ export default class LayoutHeaderComponent extends Component { }, ]); - if (universeUserMenuItems) { - for (let i = 0; i < universeUserMenuItems.length; i++) { - const menuItem = universeUserMenuItems[i]; - menuItem.text = menuItem.title; - items.insertAt(menuItem.index, menuItem); - } + // Callback to allow mutation of menu items + if (typeof this.args.mutateUserMenuItems === 'function') { + this.args.mutateUserMenuItems(menuItems); } - return items; + return menuItems; } @action routeTo(route) { @@ -218,7 +292,6 @@ export default class LayoutHeaderComponent extends Component { } hasExtension(extensionName) { - const { extensions } = this.args; - return extensions.find(({ name }) => name === extensionName); + return this.extensions.find(({ name }) => name === extensionName); } } diff --git a/addon/components/modals/changelog.hbs b/addon/components/modals/changelog.hbs index 7c59443..d7b23c8 100644 --- a/addon/components/modals/changelog.hbs +++ b/addon/components/modals/changelog.hbs @@ -7,14 +7,17 @@ {{else}} {{#each this.releases as |release|}}
-
-

{{release.name}}

+
+

{{release.name}}

+ {{release.created_at}}
    {{#each release.changes as |change|}} -
  • {{change}}
  • + {{#if change}} +
  • {{change}}
  • + {{/if}} {{/each}}
diff --git a/addon/components/toggle.hbs b/addon/components/toggle.hbs index 0953374..40ec90d 100644 --- a/addon/components/toggle.hbs +++ b/addon/components/toggle.hbs @@ -1,31 +1,41 @@ -
- - +{{#if this.visible}} +
- -
- {{yield}} - {{#if @label}} - {{@label}} - {{/if}} - {{#if @helpText}} -
- - - + role="checkbox" + tabindex="0" + aria-checked="false" + class="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 cursor-pointer group focus:outline-none {{if this.disabled 'opacity-50'}}" + data-disabled={{this.disabled}} + ...attributes + {{on "click" (fn this.toggle this.isToggled)}} + > + + + +
+ {{yield}} + {{#if @label}} + {{@label}} + {{/if}} + {{#if this.disabledByPermission}} + + -
- {{/if}} + {{else if @helpText}} +
+ + + + +
+ {{/if}} +
-
\ No newline at end of file +{{/if}} \ No newline at end of file diff --git a/addon/components/toggle.js b/addon/components/toggle.js index 536603c..6f3b164 100644 --- a/addon/components/toggle.js +++ b/addon/components/toggle.js @@ -1,8 +1,11 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; import { action, computed } from '@ember/object'; export default class ToggleComponent extends Component { + @service abilities; + /** * The active color of the toggle * @@ -17,6 +20,27 @@ export default class ToggleComponent extends Component { */ @tracked activeColor = 'green'; + /** + * The permission required. + * + * @memberof ToggleComponent + */ + @tracked permissionRequired; + + /** + * If the button is disabled by permissions. + * + * @memberof ToggleComponent + */ + @tracked disabledByPermission = false; + + /** + * Determines the visibility of the button + * + * @memberof ToggleComponent + */ + @tracked visible = true; + /** * The active color class. * Defaults to `bg-green-400` but could also be: @@ -33,11 +57,18 @@ export default class ToggleComponent extends Component { * * @memberof ToggleComponent */ - constructor(owner, { isToggled, activeColor }) { + constructor(owner, { value = false, isToggled = false, activeColor = 'green', permission = null, disabled = false, visible = true }) { super(...arguments); - this.isToggled = isToggled === true; - this.activeColor = typeof activeColor === 'string' ? activeColor : 'green'; + this.isToggled = isToggled; + this.activeColor = activeColor; + this.checked = value; + this.permissionRequired = permission; + this.visible = visible; + this.disabled = disabled; + if (!disabled) { + this.disabled = this.disabledByPermission = permission && this.abilities.cannot(permission); + } } /** @@ -46,15 +77,14 @@ export default class ToggleComponent extends Component { * @void */ @action toggle(isToggled) { - const { disabled, onToggle } = this.args; - if (disabled) { + if (this.disabled) { return; } this.isToggled = !isToggled; - if (typeof onToggle === 'function') { - onToggle(this.isToggled); + if (typeof this.args.onToggle === 'function') { + this.args.onToggle(this.isToggled); } } diff --git a/addon/helpers/component-resolvable.js b/addon/helpers/component-resolvable.js new file mode 100644 index 0000000..cc9bc73 --- /dev/null +++ b/addon/helpers/component-resolvable.js @@ -0,0 +1,10 @@ +import Helper from '@ember/component/helper'; +import { getOwner } from '@ember/application'; + +export default class ComponentResolvableHelper extends Helper { + compute(params) { + const [componentName] = params; + const owner = getOwner(this); + return owner && owner.hasRegistration(`component:${componentName}`); + } +} diff --git a/addon/helpers/has-registration.js b/addon/helpers/has-registration.js new file mode 100644 index 0000000..3fff3d3 --- /dev/null +++ b/addon/helpers/has-registration.js @@ -0,0 +1,21 @@ +import Helper from '@ember/component/helper'; +import { getOwner } from '@ember/application'; + +/** + * Checks if a registration exists in the ApplicationInstance container registry. + * Usage: `(has-registration 'component:my-component')` or `(has-registration 'my-component' 'component')` + * + * @export + * @class HasRegistrationHelper + * @extends {Helper} + */ +export default class HasRegistrationHelper extends Helper { + compute(params) { + let [name, type] = params; + if (type) { + name = `${type}:${name}`; + } + const owner = getOwner(this); + return owner && owner.hasRegistration(name); + } +} diff --git a/app/helpers/component-resolvable.js b/app/helpers/component-resolvable.js new file mode 100644 index 0000000..46bd5d7 --- /dev/null +++ b/app/helpers/component-resolvable.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/component-resolvable'; diff --git a/app/helpers/has-registration.js b/app/helpers/has-registration.js new file mode 100644 index 0000000..2283e59 --- /dev/null +++ b/app/helpers/has-registration.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/has-registration'; diff --git a/tests/integration/helpers/component-resolvable-test.js b/tests/integration/helpers/component-resolvable-test.js new file mode 100644 index 0000000..ba3d59a --- /dev/null +++ b/tests/integration/helpers/component-resolvable-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | component-resolvable', function (hooks) { + setupRenderingTest(hooks); + + // TODO: Replace this with your real tests. + test('it renders', async function (assert) { + this.set('inputValue', '1234'); + + await render(hbs`{{component-resolvable this.inputValue}}`); + + assert.dom().hasText('1234'); + }); +}); diff --git a/tests/integration/helpers/has-registration-test.js b/tests/integration/helpers/has-registration-test.js new file mode 100644 index 0000000..bf6a888 --- /dev/null +++ b/tests/integration/helpers/has-registration-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | has-registration', function (hooks) { + setupRenderingTest(hooks); + + // TODO: Replace this with your real tests. + test('it renders', async function (assert) { + this.set('inputValue', '1234'); + + await render(hbs`{{has-registration this.inputValue}}`); + + assert.dom().hasText('1234'); + }); +});