diff --git a/ui/app/components/app-breadcrumbs.js b/ui/app/components/app-breadcrumbs.js new file mode 100644 index 000000000000..28a08a85dd95 --- /dev/null +++ b/ui/app/components/app-breadcrumbs.js @@ -0,0 +1,11 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { reads } from '@ember/object/computed'; + +export default Component.extend({ + breadcrumbsService: service('breadcrumbs'), + + tagName: '', + + breadcrumbs: reads('breadcrumbsService.breadcrumbs'), +}); diff --git a/ui/app/components/job-page/abstract.js b/ui/app/components/job-page/abstract.js index d2922c2afed2..01f63b2cf7c2 100644 --- a/ui/app/components/job-page/abstract.js +++ b/ui/app/components/job-page/abstract.js @@ -1,7 +1,5 @@ import Component from '@ember/component'; -import { computed } from '@ember/object'; import { inject as service } from '@ember/service'; -import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; export default Component.extend({ system: service(), @@ -20,23 +18,6 @@ export default Component.extend({ // Set to a { title, description } to surface an error errorMessage: null, - breadcrumbs: computed('job.{name,id}', function() { - const job = this.get('job'); - return [ - { label: 'Jobs', args: ['jobs'] }, - { - label: job.get('name'), - args: [ - 'jobs.job', - job, - qpBuilder({ - jobNamespace: job.get('namespace.name') || 'default', - }), - ], - }, - ]; - }), - actions: { clearErrorMessage() { this.set('errorMessage', null); diff --git a/ui/app/controllers/allocations/allocation.js b/ui/app/controllers/allocations/allocation.js deleted file mode 100644 index 4f44f27bf2ac..000000000000 --- a/ui/app/controllers/allocations/allocation.js +++ /dev/null @@ -1,36 +0,0 @@ -import Controller from '@ember/controller'; -import { computed } from '@ember/object'; -import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; - -export default Controller.extend({ - breadcrumbs: computed('model.job', function() { - return [ - { label: 'Jobs', args: ['jobs'] }, - { - label: this.get('model.job.name'), - args: [ - 'jobs.job', - this.get('model.job.plainId'), - qpBuilder({ - jobNamespace: this.get('model.job.namespace.name') || 'default', - }), - ], - }, - { - label: this.get('model.taskGroupName'), - args: [ - 'jobs.job.task-group', - this.get('model.job'), - this.get('model.taskGroupName'), - qpBuilder({ - jobNamespace: this.get('model.namespace.name') || 'default', - }), - ], - }, - { - label: this.get('model.shortId'), - args: ['allocations.allocation', this.get('model')], - }, - ]; - }), -}); diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 1131d6b7fab4..b6fd778bae21 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,11 +1,9 @@ import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import Sortable from 'nomad-ui/mixins/sortable'; import { lazyClick } from 'nomad-ui/helpers/lazy-click'; export default Controller.extend(Sortable, { - allocationController: controller('allocations.allocation'), - queryParams: { sortProperty: 'sort', sortDescending: 'desc', @@ -14,8 +12,6 @@ export default Controller.extend(Sortable, { sortProperty: 'name', sortDescending: false, - breadcrumbs: alias('allocationController.breadcrumbs'), - listToSort: alias('model.states'), sortedStates: alias('listSorted'), diff --git a/ui/app/controllers/allocations/allocation/task.js b/ui/app/controllers/allocations/allocation/task.js deleted file mode 100644 index 616df57a3a5d..000000000000 --- a/ui/app/controllers/allocations/allocation/task.js +++ /dev/null @@ -1,15 +0,0 @@ -import Controller, { inject as controller } from '@ember/controller'; -import { computed } from '@ember/object'; - -export default Controller.extend({ - allocationController: controller('allocations.allocation'), - - breadcrumbs: computed('allocationController.breadcrumbs.[]', 'model.name', function() { - return this.get('allocationController.breadcrumbs').concat([ - { - label: this.get('model.name'), - args: ['allocations.allocation.task', this.get('model.allocation'), this.get('model')], - }, - ]); - }), -}); diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js index e45f85230ee2..c020bc4a2b61 100644 --- a/ui/app/controllers/allocations/allocation/task/index.js +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -1,12 +1,8 @@ -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { alias } from '@ember/object/computed'; export default Controller.extend({ - taskController: controller('allocations.allocation.task'), - - breadcrumbs: alias('taskController.breadcrumbs'), - network: alias('model.resources.networks.firstObject'), ports: computed('network.reservedPorts.[]', 'network.dynamicPorts.[]', function() { return (this.get('network.reservedPorts') || []) diff --git a/ui/app/controllers/allocations/allocation/task/logs.js b/ui/app/controllers/allocations/allocation/task/logs.js deleted file mode 100644 index 6ce1642b542e..000000000000 --- a/ui/app/controllers/allocations/allocation/task/logs.js +++ /dev/null @@ -1,7 +0,0 @@ -import Controller, { inject as controller } from '@ember/controller'; -import { alias } from '@ember/object/computed'; - -export default Controller.extend({ - taskController: controller('allocations.allocation.task'), - breadcrumbs: alias('taskController.breadcrumbs'), -}); diff --git a/ui/app/controllers/jobs/job.js b/ui/app/controllers/jobs/job.js deleted file mode 100644 index 29c87a2a2e49..000000000000 --- a/ui/app/controllers/jobs/job.js +++ /dev/null @@ -1,21 +0,0 @@ -import Controller from '@ember/controller'; -import { computed } from '@ember/object'; -import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; - -export default Controller.extend({ - breadcrumbs: computed('model.{name,id}', function() { - return [ - { label: 'Jobs', args: ['jobs'] }, - { - label: this.get('model.name'), - args: [ - 'jobs.job', - this.get('model.plainId'), - qpBuilder({ - jobNamespace: this.get('model.namespace.name') || 'default', - }), - ], - }, - ]; - }), -}); diff --git a/ui/app/controllers/jobs/job/definition.js b/ui/app/controllers/jobs/job/definition.js index b105b72b0633..f209d3547ad6 100644 --- a/ui/app/controllers/jobs/job/definition.js +++ b/ui/app/controllers/jobs/job/definition.js @@ -1,11 +1,4 @@ -import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -export default Controller.extend(WithNamespaceResetting, { - jobController: controller('jobs.job'), - - job: alias('model.job'), - - breadcrumbs: alias('jobController.breadcrumbs'), -}); +export default Controller.extend(WithNamespaceResetting); diff --git a/ui/app/controllers/jobs/job/deployments.js b/ui/app/controllers/jobs/job/deployments.js index 0540c98b1293..f209d3547ad6 100644 --- a/ui/app/controllers/jobs/job/deployments.js +++ b/ui/app/controllers/jobs/job/deployments.js @@ -1,12 +1,4 @@ -import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -export default Controller.extend(WithNamespaceResetting, { - jobController: controller('jobs.job'), - - job: alias('model'), - deployments: alias('model.deployments'), - - breadcrumbs: alias('jobController.breadcrumbs'), -}); +export default Controller.extend(WithNamespaceResetting); diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js index 8dc0ae650d09..617874ba5798 100644 --- a/ui/app/controllers/jobs/job/index.js +++ b/ui/app/controllers/jobs/job/index.js @@ -1,13 +1,10 @@ import { inject as service } from '@ember/service'; -import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; export default Controller.extend(WithNamespaceResetting, { system: service(), - jobController: controller('jobs.job'), - queryParams: { currentPage: 'page', sortProperty: 'sort', @@ -19,9 +16,6 @@ export default Controller.extend(WithNamespaceResetting, { sortProperty: 'name', sortDescending: false, - breadcrumbs: alias('jobController.breadcrumbs'), - job: alias('model'), - actions: { gotoTaskGroup(taskGroup) { this.transitionToRoute('jobs.job.task-group', taskGroup.get('job'), taskGroup); diff --git a/ui/app/controllers/jobs/job/loading.js b/ui/app/controllers/jobs/job/loading.js deleted file mode 100644 index 2251e2d758c1..000000000000 --- a/ui/app/controllers/jobs/job/loading.js +++ /dev/null @@ -1,7 +0,0 @@ -import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; - -export default Controller.extend({ - jobController: controller('jobs.job'), - breadcrumbs: alias('jobController.breadcrumbs'), -}); diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index 1ace41f8fe81..6f8740ce03ac 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -1,14 +1,11 @@ import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { - jobController: controller('jobs.job'), - queryParams: { currentPage: 'page', searchTerm: 'search', @@ -32,19 +29,6 @@ export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { listToSearch: alias('listSorted'), sortedAllocations: alias('listSearched'), - breadcrumbs: computed('jobController.breadcrumbs.[]', 'model.{name}', function() { - return this.get('jobController.breadcrumbs').concat([ - { - label: this.get('model.name'), - args: [ - 'jobs.job.task-group', - this.get('model.name'), - qpBuilder({ jobNamespace: this.get('model.job.namespace.name') || 'default' }), - ], - }, - ]); - }), - actions: { gotoAllocation(allocation) { this.transitionToRoute('allocations.allocation', allocation); diff --git a/ui/app/controllers/jobs/job/versions.js b/ui/app/controllers/jobs/job/versions.js index eb669a22b728..f209d3547ad6 100644 --- a/ui/app/controllers/jobs/job/versions.js +++ b/ui/app/controllers/jobs/job/versions.js @@ -1,12 +1,4 @@ -import { alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -export default Controller.extend(WithNamespaceResetting, { - jobController: controller('jobs.job'), - - job: alias('model'), - versions: alias('model.versions'), - - breadcrumbs: alias('jobController.breadcrumbs'), -}); +export default Controller.extend(WithNamespaceResetting); diff --git a/ui/app/routes/allocations/allocation.js b/ui/app/routes/allocations/allocation.js index b09dc1cb376f..9a73e184ac4f 100644 --- a/ui/app/routes/allocations/allocation.js +++ b/ui/app/routes/allocations/allocation.js @@ -3,12 +3,38 @@ import { collect } from '@ember/object/computed'; import { watchRecord } from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; import notifyError from 'nomad-ui/utils/notify-error'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; +import { jobCrumbs } from 'nomad-ui/utils/breadcrumb-utils'; export default Route.extend(WithWatchers, { startWatchers(controller, model) { controller.set('watcher', this.get('watch').perform(model)); }, + // Allocation breadcrumbs extend from job / task group breadcrumbs + // even though the route structure does not. + breadcrumbs(model) { + return [ + { label: 'Jobs', args: ['jobs.index'] }, + ...jobCrumbs(model.get('job')), + { + label: model.get('taskGroupName'), + args: [ + 'jobs.job.task-group', + model.get('job'), + model.get('taskGroupName'), + qpBuilder({ + jobNamespace: model.get('namespace.name') || 'default', + }), + ], + }, + { + label: model.get('shortId'), + args: ['allocations.allocation', model], + }, + ]; + }, + model() { // Preload the job for the allocation since it's required for the breadcrumb trail return this._super(...arguments) diff --git a/ui/app/routes/allocations/allocation/task.js b/ui/app/routes/allocations/allocation/task.js index dcf2bda101f1..c33797290545 100644 --- a/ui/app/routes/allocations/allocation/task.js +++ b/ui/app/routes/allocations/allocation/task.js @@ -5,6 +5,16 @@ import EmberError from '@ember/error'; export default Route.extend({ store: service(), + breadcrumbs(model) { + if (!model) return []; + return [ + { + label: model.get('name'), + args: ['allocations.allocation.task', model.get('allocation'), model], + }, + ]; + }, + model({ name }) { const allocation = this.modelFor('allocations.allocation'); if (allocation) { diff --git a/ui/app/routes/clients.js b/ui/app/routes/clients.js index 49559c8c96bd..b31c96990a32 100644 --- a/ui/app/routes/clients.js +++ b/ui/app/routes/clients.js @@ -8,6 +8,13 @@ export default Route.extend(WithForbiddenState, { store: service(), system: service(), + breadcrumbs: [ + { + label: 'Clients', + args: ['clients.index'], + }, + ], + beforeModel() { return this.get('system.leader'); }, diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index f049d1efe2f1..9e2493030030 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -12,6 +12,16 @@ export default Route.extend(WithWatchers, { return this._super(...arguments).catch(notifyError(this)); }, + breadcrumbs(model) { + if (!model) return []; + return [ + { + label: model.get('shortId'), + args: ['clients.client', model.get('id')], + }, + ]; + }, + afterModel(model) { if (model && model.get('isPartial')) { return model.reload().then(node => node.get('allocations')); diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index 745e326e2e26..ed9178375511 100644 --- a/ui/app/routes/jobs.js +++ b/ui/app/routes/jobs.js @@ -8,6 +8,13 @@ export default Route.extend(WithForbiddenState, { system: service(), store: service(), + breadcrumbs: [ + { + label: 'Jobs', + args: ['jobs.index'], + }, + ], + beforeModel() { return this.get('system.namespaces'); }, diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 86b784508ceb..b424fe997063 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -2,11 +2,14 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; import RSVP from 'rsvp'; import notifyError from 'nomad-ui/utils/notify-error'; +import { jobCrumbs } from 'nomad-ui/utils/breadcrumb-utils'; export default Route.extend({ store: service(), token: service(), + breadcrumbs: jobCrumbs, + serialize(model) { return { job_name: model.get('plainId') }; }, diff --git a/ui/app/routes/jobs/job/task-group.js b/ui/app/routes/jobs/job/task-group.js index 40051732492b..a219f252f75f 100644 --- a/ui/app/routes/jobs/job/task-group.js +++ b/ui/app/routes/jobs/job/task-group.js @@ -2,8 +2,23 @@ import Route from '@ember/routing/route'; import { collect } from '@ember/object/computed'; import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; export default Route.extend(WithWatchers, { + breadcrumbs(model) { + if (!model) return []; + return [ + { + label: model.get('name'), + args: [ + 'jobs.job.task-group', + model.get('name'), + qpBuilder({ jobNamespace: model.get('job.namespace.name') || 'default' }), + ], + }, + ]; + }, + model({ name }) { // If the job is a partial (from the list request) it won't have task // groups. Reload the job to ensure task groups are present. diff --git a/ui/app/routes/servers.js b/ui/app/routes/servers.js index 49559c8c96bd..8f69d7b14f23 100644 --- a/ui/app/routes/servers.js +++ b/ui/app/routes/servers.js @@ -8,6 +8,13 @@ export default Route.extend(WithForbiddenState, { store: service(), system: service(), + breadcrumbs: [ + { + label: 'Servers', + args: ['servers.index'], + }, + ], + beforeModel() { return this.get('system.leader'); }, diff --git a/ui/app/services/breadcrumbs.js b/ui/app/services/breadcrumbs.js new file mode 100644 index 000000000000..a86e9c3738f5 --- /dev/null +++ b/ui/app/services/breadcrumbs.js @@ -0,0 +1,42 @@ +import { getOwner } from '@ember/application'; +import Service, { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; + +export default Service.extend({ + router: service(), + + // currentURL is only used to listen to all transitions. + // currentRouteName has all information necessary to compute breadcrumbs, + // but it doesn't change when a transition to the same route with a different + // model occurs. + breadcrumbs: computed('router.currentURL', 'router.currentRouteName', function() { + const owner = getOwner(this); + const allRoutes = (this.get('router.currentRouteName') || '') + .split('.') + .without('') + .map((segment, index, allSegments) => allSegments.slice(0, index + 1).join('.')); + + let crumbs = []; + allRoutes.forEach(routeName => { + const route = owner.lookup(`route:${routeName}`); + + // Routes can reset the breadcrumb trail to start anew even + // if the route is deeply nested. + if (route.get('resetBreadcrumbs')) { + crumbs = []; + } + + // Breadcrumbs are either an array of static crumbs + // or a function that returns breadcrumbs given the current + // model for the route's controller. + let breadcrumbs = route.get('breadcrumbs') || []; + if (typeof breadcrumbs === 'function') { + breadcrumbs = breadcrumbs(route.get('controller.model')) || []; + } + + crumbs.push(...breadcrumbs); + }); + + return crumbs; + }), +}); diff --git a/ui/app/templates/allocations.hbs b/ui/app/templates/allocations.hbs index 8fab3547f4c7..a766b9ff647d 100644 --- a/ui/app/templates/allocations.hbs +++ b/ui/app/templates/allocations.hbs @@ -1,3 +1,6 @@
+ {{#global-header class="page-header"}} + {{app-breadcrumbs}} + {{/global-header}} {{outlet}}
diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 7abb77bfc7f9..34389a657fce 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -1,14 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body"}}

diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index ac13a5034869..d24ec3819ec3 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,14 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body"}} {{partial "allocations/allocation/task/subnav"}}
diff --git a/ui/app/templates/allocations/allocation/task/logs.hbs b/ui/app/templates/allocations/allocation/task/logs.hbs index d4ee3da568b4..6799e39d6efb 100644 --- a/ui/app/templates/allocations/allocation/task/logs.hbs +++ b/ui/app/templates/allocations/allocation/task/logs.hbs @@ -1,14 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body"}} {{partial "allocations/allocation/task/subnav"}}
diff --git a/ui/app/templates/clients.hbs b/ui/app/templates/clients.hbs index 8fab3547f4c7..a766b9ff647d 100644 --- a/ui/app/templates/clients.hbs +++ b/ui/app/templates/clients.hbs @@ -1,3 +1,6 @@
+ {{#global-header class="page-header"}} + {{app-breadcrumbs}} + {{/global-header}} {{outlet}}
diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index af3d918fa7a4..f83069ccfc1c 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -1,11 +1,3 @@ -{{#global-header class="page-header"}} -
  • - {{#link-to "clients.index" data-test-breadcrumb="clients"}}Clients{{/link-to}} -
  • -
  • - {{#link-to "clients.client" model.id data-test-breadcrumb="client"}}{{model.shortId}}{{/link-to}} -
  • -{{/global-header}} {{#gutter-menu class="page-body"}}

    diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index 83ccc379619d..2f7d0894f1d6 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -1,8 +1,3 @@ -{{#global-header class="page-header"}} -
  • - {{#link-to "clients.index"}}Clients{{/link-to}} -
  • -{{/global-header}} {{#gutter-menu class="page-body"}}
    {{#if isForbidden}} diff --git a/ui/app/templates/clients/loading.hbs b/ui/app/templates/clients/loading.hbs index 4bfa29cb2ae5..3298bc9c0745 100644 --- a/ui/app/templates/clients/loading.hbs +++ b/ui/app/templates/clients/loading.hbs @@ -1,8 +1,3 @@ -{{#global-header class="page-header"}} -
  • - {{#link-to "clients.index"}}Clients{{/link-to}} -
  • -{{/global-header}} {{#gutter-menu class="page-body"}}
    {{partial "partials/loading-spinner"}}
    {{/gutter-menu}} diff --git a/ui/app/templates/components/app-breadcrumbs.hbs b/ui/app/templates/components/app-breadcrumbs.hbs new file mode 100644 index 000000000000..cf5ec56d0425 --- /dev/null +++ b/ui/app/templates/components/app-breadcrumbs.hbs @@ -0,0 +1,13 @@ +{{#each breadcrumbs as |breadcrumb index|}} + +{{/each}} diff --git a/ui/app/templates/components/job-page/batch.hbs b/ui/app/templates/components/job-page/batch.hbs index 1ea0cedf9e08..d9f8a1fa6753 100644 --- a/ui/app/templates/components/job-page/batch.hbs +++ b/ui/app/templates/components/job-page/batch.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs index d9104b57114e..f0fcad10e250 100644 --- a/ui/app/templates/components/job-page/parameterized-child.hbs +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/parameterized.hbs b/ui/app/templates/components/job-page/parameterized.hbs index b976e73a8954..8fca16924a41 100644 --- a/ui/app/templates/components/job-page/parameterized.hbs +++ b/ui/app/templates/components/job-page/parameterized.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs index 117a866799ef..398cb9327048 100644 --- a/ui/app/templates/components/job-page/periodic-child.hbs +++ b/ui/app/templates/components/job-page/periodic-child.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/periodic.hbs b/ui/app/templates/components/job-page/periodic.hbs index 31e0ffe32e3a..bc70771dccb2 100644 --- a/ui/app/templates/components/job-page/periodic.hbs +++ b/ui/app/templates/components/job-page/periodic.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index b724b7ab5afb..f65dd4b6f06b 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs index 1ea0cedf9e08..d9f8a1fa6753 100644 --- a/ui/app/templates/components/job-page/system.hbs +++ b/ui/app/templates/components/job-page/system.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}} diff --git a/ui/app/templates/jobs.hbs b/ui/app/templates/jobs.hbs index 8fab3547f4c7..a766b9ff647d 100644 --- a/ui/app/templates/jobs.hbs +++ b/ui/app/templates/jobs.hbs @@ -1,3 +1,6 @@
    + {{#global-header class="page-header"}} + {{app-breadcrumbs}} + {{/global-header}} {{outlet}}
    diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 8767c2355ab3..7b409cadbba1 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -1,8 +1,3 @@ -{{#global-header class="page-header"}} -
  • - {{#link-to "jobs.index"}}Jobs{{/link-to}} -
  • -{{/global-header}} {{#gutter-menu class="page-body" onNamespaceChange=(action "refresh")}}
    {{#if isForbidden}} diff --git a/ui/app/templates/jobs/job/definition.hbs b/ui/app/templates/jobs/job/definition.hbs index d2722fa6a4f2..924d180002ea 100644 --- a/ui/app/templates/jobs/job/definition.hbs +++ b/ui/app/templates/jobs/job/definition.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} {{partial "jobs/job/subnav"}}
    diff --git a/ui/app/templates/jobs/job/deployments.hbs b/ui/app/templates/jobs/job/deployments.hbs index c293bfaef7e9..241a0d32d843 100644 --- a/ui/app/templates/jobs/job/deployments.hbs +++ b/ui/app/templates/jobs/job/deployments.hbs @@ -1,13 +1,6 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} {{partial "jobs/job/subnav"}}
    - {{job-deployments-stream deployments=deployments}} + {{job-deployments-stream deployments=model.deployments}}
    {{/gutter-menu}} diff --git a/ui/app/templates/jobs/job/loading.hbs b/ui/app/templates/jobs/job/loading.hbs index 666de358d35a..a90321c319a1 100644 --- a/ui/app/templates/jobs/job/loading.hbs +++ b/ui/app/templates/jobs/job/loading.hbs @@ -1,10 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body"}} {{partial "jobs/job/subnav"}}
    {{partial "partials/loading-spinner"}}
    diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 702168f2275a..b2b08c70162b 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -1,14 +1,3 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}}
      diff --git a/ui/app/templates/jobs/job/versions.hbs b/ui/app/templates/jobs/job/versions.hbs index 6aadbe617232..9b03e23ad364 100644 --- a/ui/app/templates/jobs/job/versions.hbs +++ b/ui/app/templates/jobs/job/versions.hbs @@ -1,13 +1,6 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} {{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} {{partial "jobs/job/subnav"}}
      - {{job-versions-stream versions=versions verbose=true}} + {{job-versions-stream versions=model.versions verbose=true}}
      {{/gutter-menu}} diff --git a/ui/app/templates/jobs/loading.hbs b/ui/app/templates/jobs/loading.hbs index 58bc6ef5b04c..39002f28aa7f 100644 --- a/ui/app/templates/jobs/loading.hbs +++ b/ui/app/templates/jobs/loading.hbs @@ -1,7 +1,5 @@ {{#global-header class="page-header"}} -
    • - {{#link-to "jobs.index"}}Jobs{{/link-to}} -
    • + {{app-breadcrumbs}} {{/global-header}} {{#gutter-menu class="page-body"}}
      {{partial "partials/loading-spinner"}}
      diff --git a/ui/app/templates/loading.hbs b/ui/app/templates/loading.hbs index 14013c55beb2..206f1ebac97d 100644 --- a/ui/app/templates/loading.hbs +++ b/ui/app/templates/loading.hbs @@ -1,5 +1,6 @@
      {{#global-header class="page-header"}} + {{app-breadcrumbs}} {{/global-header}} {{#gutter-menu class="page-body"}}
      {{partial "partials/loading-spinner"}}
      diff --git a/ui/app/templates/servers.hbs b/ui/app/templates/servers.hbs index 6a639191d051..a2bdbc84db45 100644 --- a/ui/app/templates/servers.hbs +++ b/ui/app/templates/servers.hbs @@ -1,8 +1,6 @@
      {{#global-header class="page-header"}} -
    • - {{#link-to "servers.index"}}Servers{{/link-to}} -
    • + {{app-breadcrumbs}} {{/global-header}} {{#gutter-menu class="page-body"}}
      diff --git a/ui/app/templates/settings.hbs b/ui/app/templates/settings.hbs index e781df8a2f1c..2e4ddc936321 100644 --- a/ui/app/templates/settings.hbs +++ b/ui/app/templates/settings.hbs @@ -1,5 +1,6 @@
      {{#global-header class="page-header"}} + {{app-breadcrumbs}} {{/global-header}} {{#gutter-menu class="page-body"}} {{outlet}} diff --git a/ui/app/utils/breadcrumb-utils.js b/ui/app/utils/breadcrumb-utils.js new file mode 100644 index 000000000000..699d3f87522c --- /dev/null +++ b/ui/app/utils/breadcrumb-utils.js @@ -0,0 +1,28 @@ +import PromiseObject from 'nomad-ui/utils/classes/promise-object'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; + +export const jobCrumb = job => ({ + label: job.get('trimmedName'), + args: [ + 'jobs.job.index', + job.get('plainId'), + qpBuilder({ + jobNamespace: job.get('namespace.name') || 'default', + }), + ], +}); + +export const jobCrumbs = job => { + if (!job) return []; + + if (job.get('parent.content')) { + return [ + PromiseObject.create({ + promise: job.get('parent').then(parent => jobCrumb(parent)), + }), + jobCrumb(job), + ]; + } else { + return [jobCrumb(job)]; + } +}; diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index 72204b92b2d1..3d72e868deba 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -26,19 +26,19 @@ test('/clients/:id should have a breadcrumb trail linking back to clients', func andThen(() => { assert.equal( - find('[data-test-breadcrumb="clients"]').textContent.trim(), + find('[data-test-breadcrumb="clients.index"]').textContent.trim(), 'Clients', 'First breadcrumb says clients' ); assert.equal( - find('[data-test-breadcrumb="client"]').textContent.trim(), + find('[data-test-breadcrumb="clients.client"]').textContent.trim(), node.id.split('-')[0], 'Second breadcrumb says the node short id' ); }); andThen(() => { - click(find('[data-test-breadcrumb="clients"]')); + click(find('[data-test-breadcrumb="clients.index"]')); }); andThen(() => { diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index ee9e83821f5d..712913f1002f 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -37,32 +37,32 @@ test('breadcrumbs match jobs / job / task group / allocation / task', function(a const shortId = allocation.id.split('-')[0]; assert.equal( - find('[data-test-breadcrumb="Jobs"]').textContent.trim(), + find('[data-test-breadcrumb="jobs.index"]').textContent.trim(), 'Jobs', 'Jobs is the first breadcrumb' ); assert.equal( - find(`[data-test-breadcrumb="${job.name}"]`).textContent.trim(), + find('[data-test-breadcrumb="jobs.job.index"]').textContent.trim(), job.name, 'Job is the second breadcrumb' ); assert.equal( - find(`[data-test-breadcrumb="${taskGroup}`).textContent.trim(), + find('[data-test-breadcrumb="jobs.job.task-group"]').textContent.trim(), taskGroup, 'Task Group is the third breadcrumb' ); assert.equal( - find(`[data-test-breadcrumb="${shortId}"]`).textContent.trim(), + find('[data-test-breadcrumb="allocations.allocation"]').textContent.trim(), shortId, 'Allocation short id is the fourth breadcrumb' ); assert.equal( - find(`[data-test-breadcrumb="${task.name}"]`).textContent.trim(), + find('[data-test-breadcrumb="allocations.allocation.task"]').textContent.trim(), task.name, 'Task name is the fifth breadcrumb' ); - click('[data-test-breadcrumb="Jobs"]'); + click('[data-test-breadcrumb="jobs.index"]'); andThen(() => { assert.equal(currentURL(), '/jobs', 'Jobs breadcrumb links correctly'); }); @@ -70,7 +70,7 @@ test('breadcrumbs match jobs / job / task group / allocation / task', function(a visit(`/allocations/${allocation.id}/${task.name}`); }); andThen(() => { - click(`[data-test-breadcrumb="${job.name}"]`); + click('[data-test-breadcrumb="jobs.job.index"]'); }); andThen(() => { assert.equal(currentURL(), `/jobs/${job.id}`, 'Job breadcrumb links correctly'); @@ -79,7 +79,7 @@ test('breadcrumbs match jobs / job / task group / allocation / task', function(a visit(`/allocations/${allocation.id}/${task.name}`); }); andThen(() => { - click(`[data-test-breadcrumb="${taskGroup}"]`); + click('[data-test-breadcrumb="jobs.job.task-group"]'); }); andThen(() => { assert.equal( @@ -92,7 +92,7 @@ test('breadcrumbs match jobs / job / task group / allocation / task', function(a visit(`/allocations/${allocation.id}/${task.name}`); }); andThen(() => { - click(`[data-test-breadcrumb="${shortId}"]`); + click('[data-test-breadcrumb="allocations.allocation"]'); }); andThen(() => { assert.equal( diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 7db6f6f27452..7c99f4a18c40 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -86,31 +86,31 @@ test('/jobs/:id/:task-group should list high-level metrics for the allocation', test('/jobs/:id/:task-group should have breadcrumbs for job and jobs', function(assert) { assert.equal( - find('[data-test-breadcrumb="Jobs"]').textContent.trim(), + find('[data-test-breadcrumb="jobs.index"]').textContent.trim(), 'Jobs', 'First breadcrumb says jobs' ); assert.equal( - find(`[data-test-breadcrumb="${job.name}"]`).textContent.trim(), + find('[data-test-breadcrumb="jobs.job.index"]').textContent.trim(), job.name, 'Second breadcrumb says the job name' ); assert.equal( - find(`[data-test-breadcrumb="${taskGroup.name}"]`).textContent.trim(), + find('[data-test-breadcrumb="jobs.job.task-group"]').textContent.trim(), taskGroup.name, 'Third breadcrumb says the job name' ); }); test('/jobs/:id/:task-group first breadcrumb should link to jobs', function(assert) { - click('[data-test-breadcrumb="Jobs"]'); + click('[data-test-breadcrumb="jobs.index"]'); andThen(() => { assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs'); }); }); test('/jobs/:id/:task-group second breadcrumb should link to the job for the task group', function(assert) { - click(`[data-test-breadcrumb="${job.name}"]`); + click('[data-test-breadcrumb="jobs.job.index"]'); andThen(() => { assert.equal( currentURL(), diff --git a/ui/tests/integration/app-breadcrumbs-test.js b/ui/tests/integration/app-breadcrumbs-test.js new file mode 100644 index 000000000000..c046791613e2 --- /dev/null +++ b/ui/tests/integration/app-breadcrumbs-test.js @@ -0,0 +1,85 @@ +import Service from '@ember/service'; +import { getOwner } from '@ember/application'; +import RSVP from 'rsvp'; +import { test, moduleForComponent } from 'ember-qunit'; +import { findAll } from 'ember-native-dom-helpers'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import PromiseObject from 'nomad-ui/utils/classes/promise-object'; + +moduleForComponent('app-breadcrumbs', 'Integration | Component | app breadcrumbs', { + integration: true, + beforeEach() { + const mockBreadcrumbs = Service.extend({ + breadcrumbs: [], + }); + + this.register('service:breadcrumbs', mockBreadcrumbs); + this.breadcrumbs = getOwner(this).lookup('service:breadcrumbs'); + }, +}); + +const commonCrumbs = [{ label: 'One', args: ['one'] }, { label: 'Two', args: ['two'] }]; + +const template = hbs` + {{app-breadcrumbs}} +`; + +test('breadcrumbs comes from the breadcrumbs service', function(assert) { + this.breadcrumbs.set('breadcrumbs', commonCrumbs); + + this.render(template); + + assert.equal( + findAll('[data-test-breadcrumb]').length, + commonCrumbs.length, + 'The number of crumbs matches the crumbs from the service' + ); +}); + +test('every breadcrumb is rendered correctly', function(assert) { + this.breadcrumbs.set('breadcrumbs', commonCrumbs); + + this.render(template); + + const renderedCrumbs = findAll('[data-test-breadcrumb]'); + + renderedCrumbs.forEach((crumb, index) => { + assert.equal( + crumb.textContent.trim(), + commonCrumbs[index].label, + `Crumb ${index} is ${commonCrumbs[index].label}` + ); + }); +}); + +test('when breadcrumbs are pending promises, an ellipsis is rendered', function(assert) { + let resolvePromise; + const promise = new RSVP.Promise(resolve => { + resolvePromise = resolve; + }); + + this.breadcrumbs.set('breadcrumbs', [ + { label: 'One', args: ['one'] }, + PromiseObject.create({ promise }), + { label: 'Three', args: ['three'] }, + ]); + + this.render(template); + + assert.equal( + findAll('[data-test-breadcrumb]')[1].textContent.trim(), + '…', + 'Promise breadcrumb is in a loading state' + ); + + resolvePromise({ label: 'Two', args: ['two'] }); + + return wait().then(() => { + assert.equal( + findAll('[data-test-breadcrumb]')[1].textContent.trim(), + 'Two', + 'Promise breadcrumb has resolved and now renders Two' + ); + }); +}); diff --git a/ui/tests/unit/services/breadcrumbs-test.js b/ui/tests/unit/services/breadcrumbs-test.js new file mode 100644 index 000000000000..6ec833556c12 --- /dev/null +++ b/ui/tests/unit/services/breadcrumbs-test.js @@ -0,0 +1,149 @@ +import Service from '@ember/service'; +import Route from '@ember/routing/route'; +import Controller from '@ember/controller'; +import { get } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { getOwner } from '@ember/application'; +import RSVP from 'rsvp'; +import { moduleFor, test } from 'ember-qunit'; +import PromiseObject from 'nomad-ui/utils/classes/promise-object'; + +const makeRoute = (crumbs, controller = {}) => + Route.extend({ + breadcrumbs: crumbs, + controller: Controller.extend(controller).create(), + }); + +moduleFor('service:breadcrumbs', 'Unit | Service | Breadcrumbs', { + beforeEach() { + const mockRouter = Service.extend({ + currentRouteName: 'application', + currentURL: '/', + }); + + this.register('service:router', mockRouter); + this.router = getOwner(this).lookup('service:router'); + + const none = makeRoute(); + const fixed = makeRoute([{ label: 'Static', args: ['static.index'] }]); + const manyFixed = makeRoute([ + { label: 'Static 1', args: ['static.index', 1] }, + { label: 'Static 2', args: ['static.index', 2] }, + ]); + const dynamic = makeRoute(model => [{ label: model, args: ['dynamic.index', model] }], { + model: 'Label of the Crumb', + }); + const manyDynamic = makeRoute( + model => [ + { label: get(model, 'fishOne'), args: ['dynamic.index', get(model, 'fishOne')] }, + { label: get(model, 'fishTwo'), args: ['dynamic.index', get(model, 'fishTwo')] }, + ], + { + model: { + fishOne: 'red', + fishTwo: 'blue', + }, + } + ); + const promise = makeRoute([ + PromiseObject.create({ + promise: RSVP.Promise.resolve({ + label: 'delayed', + args: ['wait.for.it'], + }), + }), + ]); + const fromURL = makeRoute(model => [{ label: model, args: ['url'] }], { + router: getOwner(this).lookup('service:router'), + model: alias('router.currentURL'), + }); + + this.register('route:none', none); + this.register('route:none.more-none', none); + this.register('route:static', fixed); + this.register('route:static.many', manyFixed); + this.register('route:dynamic', dynamic); + this.register('route:dynamic.many', manyDynamic); + this.register('route:promise', promise); + this.register('route:url', fromURL); + }, + + subject() { + return getOwner(this) + .factoryFor('service:breadcrumbs') + .create(); + }, +}); + +test('when the route hierarchy has no breadcrumbs', function(assert) { + this.router.set('currentRouteName', 'none'); + + const service = this.subject(); + assert.deepEqual(service.get('breadcrumbs'), []); +}); + +test('when the route hierarchy has one segment with static crumbs', function(assert) { + this.router.set('currentRouteName', 'static'); + + const service = this.subject(); + assert.deepEqual(service.get('breadcrumbs'), [{ label: 'Static', args: ['static.index'] }]); +}); + +test('when the route hierarchy has multiple segments with static crumbs', function(assert) { + this.router.set('currentRouteName', 'static.many'); + + const service = this.subject(); + assert.deepEqual(service.get('breadcrumbs'), [ + { label: 'Static', args: ['static.index'] }, + { label: 'Static 1', args: ['static.index', 1] }, + { label: 'Static 2', args: ['static.index', 2] }, + ]); +}); + +test('when the route hierarchy has a function as its breadcrumbs property', function(assert) { + this.router.set('currentRouteName', 'dynamic'); + + const service = this.subject(); + assert.deepEqual(service.get('breadcrumbs'), [ + { label: 'Label of the Crumb', args: ['dynamic.index', 'Label of the Crumb'] }, + ]); +}); + +test('when the route hierarchy has multiple segments with dynamic crumbs', function(assert) { + this.router.set('currentRouteName', 'dynamic.many'); + + const service = this.subject(); + assert.deepEqual(service.get('breadcrumbs'), [ + { label: 'Label of the Crumb', args: ['dynamic.index', 'Label of the Crumb'] }, + { label: 'red', args: ['dynamic.index', 'red'] }, + { label: 'blue', args: ['dynamic.index', 'blue'] }, + ]); +}); + +test('when a route provides a breadcrumb that is a promise, it gets passed through to the template', function(assert) { + this.router.set('currentRouteName', 'promise'); + + const service = this.subject(); + assert.ok(service.get('breadcrumbs.firstObject') instanceof PromiseObject); +}); + +// This happens when transitioning to the current route but with a different model +// jobs.job.index --> jobs.job.index +// /jobs/one --> /jobs/two +test('when the route stays the same but the url changes, breadcrumbs get recomputed', function(assert) { + this.router.set('currentRouteName', 'url'); + + const service = this.subject(); + assert.deepEqual( + service.get('breadcrumbs'), + [{ label: '/', args: ['url'] }], + 'The label is initially / as is the router currentURL' + ); + + this.router.set('currentURL', '/somewhere/else'); + assert.deepEqual( + service.get('breadcrumbs'), + [{ label: '/somewhere/else', args: ['url'] }], + 'The label changes with currentURL since it is an alias and a change to currentURL recomputes breadcrumbs' + ); +});