mirror of
https://github.com/kemko/nomad.git
synced 2026-01-04 17:35:43 +03:00
ui: Update namespaces design (#10444)
This rethinks namespaces as a filter on list pages rather than a global setting. The biggest net-new feature here is being able to select All (*) to list all jobs or CSI volumes across namespaces.
This commit is contained in:
@@ -12,19 +12,24 @@ export default class Abstract extends Ability {
|
||||
@not('token.aclEnabled') bypassAuthorization;
|
||||
@equal('token.selfToken.type', 'management') selfTokenIsManagement;
|
||||
|
||||
@computed('system.activeNamespace.name')
|
||||
get activeNamespace() {
|
||||
return this.get('system.activeNamespace.name') || 'default';
|
||||
// Pass in a namespace to `can` or `cannot` calls to override
|
||||
// https://github.com/minutebase/ember-can#additional-attributes
|
||||
namespace = 'default';
|
||||
|
||||
get _namespace() {
|
||||
if (!this.namespace) return 'default';
|
||||
if (typeof this.namespace === 'string') return this.namespace;
|
||||
return get(this.namespace, 'name');
|
||||
}
|
||||
|
||||
@computed('activeNamespace', 'token.selfTokenPolicies.[]')
|
||||
get rulesForActiveNamespace() {
|
||||
let activeNamespace = this.activeNamespace;
|
||||
@computed('_namespace', 'token.selfTokenPolicies.[]')
|
||||
get rulesForNamespace() {
|
||||
let namespace = this._namespace;
|
||||
|
||||
return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
|
||||
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];
|
||||
|
||||
let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);
|
||||
let matchingNamespace = this._findMatchingNamespace(policyNamespaces, namespace);
|
||||
|
||||
if (matchingNamespace) {
|
||||
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
|
||||
@@ -46,8 +51,8 @@ export default class Abstract extends Ability {
|
||||
}, []);
|
||||
}
|
||||
|
||||
activeNamespaceIncludesCapability(capability) {
|
||||
return this.rulesForActiveNamespace.some(rules => {
|
||||
namespaceIncludesCapability(capability) {
|
||||
return this.rulesForNamespace.some(rules => {
|
||||
let capabilities = get(rules, 'Capabilities') || [];
|
||||
return capabilities.includes(capability);
|
||||
});
|
||||
@@ -64,11 +69,11 @@ export default class Abstract extends Ability {
|
||||
|
||||
// Chooses the closest namespace as described at the bottom here:
|
||||
// https://learn.hashicorp.com/tutorials/nomad/access-control-policies?in=nomad/access-control#namespace-rules
|
||||
_findMatchingNamespace(policyNamespaces, activeNamespace) {
|
||||
_findMatchingNamespace(policyNamespaces, namespace) {
|
||||
let namespaceNames = policyNamespaces.mapBy('Name');
|
||||
|
||||
if (namespaceNames.includes(activeNamespace)) {
|
||||
return activeNamespace;
|
||||
if (namespaceNames.includes(namespace)) {
|
||||
return namespace;
|
||||
}
|
||||
|
||||
let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));
|
||||
@@ -77,11 +82,11 @@ export default class Abstract extends Ability {
|
||||
(mostMatching, namespaceName) => {
|
||||
// Convert * wildcards to .* for regex matching
|
||||
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
|
||||
let characterDifference = activeNamespace.length - namespaceName.length;
|
||||
let characterDifference = namespace.length - namespaceName.length;
|
||||
|
||||
if (
|
||||
characterDifference < mostMatching.mostMatchingCharacterDifference &&
|
||||
activeNamespace.match(namespaceNameRegExp)
|
||||
namespace.match(namespaceNameRegExp)
|
||||
) {
|
||||
return {
|
||||
mostMatchingNamespaceName: namespaceName,
|
||||
|
||||
@@ -6,9 +6,9 @@ export default class Allocation extends AbstractAbility {
|
||||
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportExec')
|
||||
canExec;
|
||||
|
||||
@computed('rulesForActiveNamespace.@each.capabilities')
|
||||
@computed('rulesForNamespace.@each.capabilities')
|
||||
get policiesSupportExec() {
|
||||
return this.rulesForActiveNamespace.some(rules => {
|
||||
return this.rulesForNamespace.some(rules => {
|
||||
let capabilities = get(rules, 'Capabilities') || [];
|
||||
return capabilities.includes('alloc-exec');
|
||||
});
|
||||
|
||||
@@ -20,13 +20,13 @@ export default class Job extends AbstractAbility {
|
||||
@or('bypassAuthorization', 'selfTokenIsManagement')
|
||||
canListAll;
|
||||
|
||||
@computed('rulesForActiveNamespace.@each.capabilities')
|
||||
@computed('rulesForNamespace.@each.capabilities')
|
||||
get policiesSupportRunning() {
|
||||
return this.activeNamespaceIncludesCapability('submit-job');
|
||||
return this.namespaceIncludesCapability('submit-job');
|
||||
}
|
||||
|
||||
@computed('rulesForActiveNamespace.@each.capabilities')
|
||||
@computed('rulesForNamespace.@each.capabilities')
|
||||
get policiesSupportScaling() {
|
||||
return this.activeNamespaceIncludesCapability('scale-job');
|
||||
return this.namespaceIncludesCapability('scale-job');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ApplicationAdapter from './application';
|
||||
import Watchable from './watchable';
|
||||
import codesForError from '../utils/codes-for-error';
|
||||
|
||||
export default class NamespaceAdapter extends ApplicationAdapter {
|
||||
export default class NamespaceAdapter extends Watchable {
|
||||
findRecord(store, modelClass, id) {
|
||||
return super.findRecord(...arguments).catch(error => {
|
||||
const errorCodes = codesForError(error);
|
||||
|
||||
@@ -7,10 +7,18 @@ export default class WatchableNamespaceIDs extends Watchable {
|
||||
@service system;
|
||||
|
||||
findAll() {
|
||||
const namespace = this.get('system.activeNamespace');
|
||||
return super.findAll(...arguments).then(data => {
|
||||
data.forEach(record => {
|
||||
record.Namespace = namespace ? namespace.get('id') : 'default';
|
||||
record.Namespace = 'default';
|
||||
});
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
query(store, type, { namespace }) {
|
||||
return super.query(...arguments).then(data => {
|
||||
data.forEach(record => {
|
||||
if (!record.Namespace) record.Namespace = namespace;
|
||||
});
|
||||
return data;
|
||||
});
|
||||
@@ -25,14 +33,12 @@ export default class WatchableNamespaceIDs extends Watchable {
|
||||
|
||||
urlForFindAll() {
|
||||
const url = super.urlForFindAll(...arguments);
|
||||
const namespace = this.get('system.activeNamespace.id');
|
||||
return associateNamespace(url, namespace);
|
||||
return associateNamespace(url);
|
||||
}
|
||||
|
||||
urlForQuery() {
|
||||
const url = super.urlForQuery(...arguments);
|
||||
const namespace = this.get('system.activeNamespace.id');
|
||||
return associateNamespace(url, namespace);
|
||||
return associateNamespace(url);
|
||||
}
|
||||
|
||||
urlForFindRecord(id, type, hash, pathSuffix) {
|
||||
|
||||
@@ -48,11 +48,12 @@ export default class Watchable extends ApplicationAdapter {
|
||||
}
|
||||
|
||||
findRecord(store, type, id, snapshot, additionalParams = {}) {
|
||||
let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?');
|
||||
const originalUrl = this.buildURL(type.modelName, id, snapshot, 'findRecord');
|
||||
let [url, params] = originalUrl.split('?');
|
||||
params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams);
|
||||
|
||||
if (get(snapshot || {}, 'adapterOptions.watch')) {
|
||||
params.index = this.watchList.getIndexFor(url);
|
||||
params.index = this.watchList.getIndexFor(originalUrl);
|
||||
}
|
||||
|
||||
const signal = get(snapshot || {}, 'adapterOptions.abortController.signal');
|
||||
|
||||
@@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator';
|
||||
@tagName('tr')
|
||||
@classNames('job-row', 'is-interactive')
|
||||
export default class JobRow extends Component {
|
||||
@service system;
|
||||
@service store;
|
||||
|
||||
job = null;
|
||||
|
||||
13
ui/app/components/single-select-dropdown/index.hbs
Normal file
13
ui/app/components/single-select-dropdown/index.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
<div data-test-single-select-dropdown class="dropdown" ...attributes>
|
||||
<PowerSelect
|
||||
@options={{@options}}
|
||||
@selected={{this.activeOption}}
|
||||
@searchEnabled={{gt @options.length 10}}
|
||||
@searchField="label"
|
||||
@onChange={{action this.setSelection}}
|
||||
@dropdownClass="dropdown-options"
|
||||
as |option|>
|
||||
<span class="ember-power-select-prefix">{{@label}}: </span>
|
||||
<span class="dropdown-label" data-test-dropdown-option={{option.key}}>{{option.label}}</span>
|
||||
</PowerSelect>
|
||||
</div>
|
||||
13
ui/app/components/single-select-dropdown/index.js
Normal file
13
ui/app/components/single-select-dropdown/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class SingleSelectDropdown extends Component {
|
||||
get activeOption() {
|
||||
return this.args.options.findBy('key', this.args.selection);
|
||||
}
|
||||
|
||||
@action
|
||||
setSelection({ key }) {
|
||||
this.args.onSelect && this.args.onSelect(key);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class VolumesController extends Controller {
|
||||
queryParams = [
|
||||
{
|
||||
volumeNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
|
||||
isForbidden = false;
|
||||
|
||||
volumeNamespace = 'default';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action, computed } from '@ember/object';
|
||||
import { alias, readOnly } from '@ember/object/computed';
|
||||
import { scheduleOnce } from '@ember/runloop';
|
||||
import Controller, { inject as controller } from '@ember/controller';
|
||||
import SortableFactory from 'nomad-ui/mixins/sortable-factory';
|
||||
import Searchable from 'nomad-ui/mixins/searchable';
|
||||
import { lazyClick } from 'nomad-ui/helpers/lazy-click';
|
||||
import { serialize } from 'nomad-ui/utils/qp-serialize';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
@@ -38,6 +40,9 @@ export default class IndexController extends Controller.extend(
|
||||
{
|
||||
sortDescending: 'desc',
|
||||
},
|
||||
{
|
||||
qpNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
|
||||
currentPage = 1;
|
||||
@@ -58,29 +63,60 @@ export default class IndexController extends Controller.extend(
|
||||
|
||||
fuzzySearchEnabled = true;
|
||||
|
||||
@computed('qpNamespace', 'model.namespaces.[]', 'system.cachedNamespace')
|
||||
get optionsNamespaces() {
|
||||
const availableNamespaces = this.model.namespaces.map(namespace => ({
|
||||
key: namespace.name,
|
||||
label: namespace.name,
|
||||
}));
|
||||
|
||||
availableNamespaces.unshift({
|
||||
key: '*',
|
||||
label: 'All (*)',
|
||||
});
|
||||
|
||||
// Unset the namespace selection if it was server-side deleted
|
||||
if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) {
|
||||
// eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.set('qpNamespace', this.system.cachedNamespace || 'default');
|
||||
});
|
||||
}
|
||||
|
||||
return availableNamespaces;
|
||||
}
|
||||
|
||||
/**
|
||||
Visible volumes are those that match the selected namespace
|
||||
*/
|
||||
@computed('model.@each.parent', 'system.{activeNamespace.id,namespaces.length}')
|
||||
@computed('model.volumes.@each.parent', 'system.{namespaces.length}')
|
||||
get visibleVolumes() {
|
||||
if (!this.model) return [];
|
||||
|
||||
// Namespace related properties are ommitted from the dependent keys
|
||||
// due to a prop invalidation bug caused by region switching.
|
||||
const hasNamespaces = this.get('system.namespaces.length');
|
||||
const activeNamespace = this.get('system.activeNamespace.id') || 'default';
|
||||
|
||||
return this.model
|
||||
.compact()
|
||||
.filter(volume => !hasNamespaces || volume.get('namespace.id') === activeNamespace);
|
||||
if (!this.model.volumes) return [];
|
||||
return this.model.volumes.compact();
|
||||
}
|
||||
|
||||
@alias('visibleVolumes') listToSort;
|
||||
@alias('listSorted') listToSearch;
|
||||
@alias('listSearched') sortedVolumes;
|
||||
|
||||
@action
|
||||
cacheNamespace(namespace) {
|
||||
this.system.cachedNamespace = namespace;
|
||||
}
|
||||
|
||||
setFacetQueryParam(queryParam, selection) {
|
||||
this.set(queryParam, serialize(selection));
|
||||
}
|
||||
|
||||
@action
|
||||
gotoVolume(volume, event) {
|
||||
lazyClick([() => this.transitionToRoute('csi.volumes.volume', volume.get('plainId')), event]);
|
||||
lazyClick([
|
||||
() =>
|
||||
this.transitionToRoute('csi.volumes.volume', volume.get('plainId'), {
|
||||
queryParams: { volumeNamespace: volume.get('namespace.name') },
|
||||
}),
|
||||
event,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ export default class VolumeController extends Controller {
|
||||
// Used in the template
|
||||
@service system;
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
volumeNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
volumeNamespace = 'default';
|
||||
|
||||
@computed('model.readAllocations.@each.modifyIndex')
|
||||
get sortedReadAllocations() {
|
||||
return this.model.readAllocations.sortBy('modifyIndex').reverse();
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class JobsController extends Controller {
|
||||
@service system;
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
jobNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
|
||||
isForbidden = false;
|
||||
|
||||
jobNamespace = 'default';
|
||||
}
|
||||
export default class JobsController extends Controller {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
|
||||
import { inject as service } from '@ember/service';
|
||||
import { alias, readOnly } from '@ember/object/computed';
|
||||
import Controller, { inject as controller } from '@ember/controller';
|
||||
import Controller from '@ember/controller';
|
||||
import { action, computed } from '@ember/object';
|
||||
import { scheduleOnce } from '@ember/runloop';
|
||||
import intersection from 'lodash.intersection';
|
||||
@@ -14,9 +14,8 @@ import classic from 'ember-classic-decorator';
|
||||
export default class IndexController extends Controller.extend(Sortable, Searchable) {
|
||||
@service system;
|
||||
@service userSettings;
|
||||
@controller('jobs') jobsController;
|
||||
|
||||
@alias('jobsController.isForbidden') isForbidden;
|
||||
isForbidden = false;
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
@@ -43,6 +42,9 @@ export default class IndexController extends Controller.extend(Sortable, Searcha
|
||||
{
|
||||
qpPrefix: 'prefix',
|
||||
},
|
||||
{
|
||||
qpNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
|
||||
currentPage = 1;
|
||||
@@ -150,21 +152,39 @@ export default class IndexController extends Controller.extend(Sortable, Searcha
|
||||
}));
|
||||
}
|
||||
|
||||
@computed('qpNamespace', 'model.namespaces.[]', 'system.cachedNamespace')
|
||||
get optionsNamespaces() {
|
||||
const availableNamespaces = this.model.namespaces.map(namespace => ({
|
||||
key: namespace.name,
|
||||
label: namespace.name,
|
||||
}));
|
||||
|
||||
availableNamespaces.unshift({
|
||||
key: '*',
|
||||
label: 'All (*)',
|
||||
});
|
||||
|
||||
// Unset the namespace selection if it was server-side deleted
|
||||
if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) {
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.set('qpNamespace', this.system.cachedNamespace || 'default');
|
||||
});
|
||||
}
|
||||
|
||||
return availableNamespaces;
|
||||
}
|
||||
|
||||
/**
|
||||
Visible jobs are those that match the selected namespace and aren't children
|
||||
of periodic or parameterized jobs.
|
||||
*/
|
||||
@computed('model.@each.parent', 'system.{activeNamespace.id,namespaces.length}')
|
||||
@computed('model.jobs.@each.parent')
|
||||
get visibleJobs() {
|
||||
// Namespace related properties are ommitted from the dependent keys
|
||||
// due to a prop invalidation bug caused by region switching.
|
||||
const hasNamespaces = this.get('system.namespaces.length');
|
||||
const activeNamespace = this.get('system.activeNamespace.id') || 'default';
|
||||
|
||||
return this.model
|
||||
if (!this.model || !this.model.jobs) return [];
|
||||
return this.model.jobs
|
||||
.compact()
|
||||
.filter(job => !job.isNew)
|
||||
.filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace)
|
||||
.filter(job => !job.get('parent.content'));
|
||||
}
|
||||
|
||||
@@ -213,12 +233,19 @@ export default class IndexController extends Controller.extend(Sortable, Searcha
|
||||
|
||||
isShowingDeploymentDetails = false;
|
||||
|
||||
@action
|
||||
cacheNamespace(namespace) {
|
||||
this.system.cachedNamespace = namespace;
|
||||
}
|
||||
|
||||
setFacetQueryParam(queryParam, selection) {
|
||||
this.set(queryParam, serialize(selection));
|
||||
}
|
||||
|
||||
@action
|
||||
gotoJob(job) {
|
||||
this.transitionToRoute('jobs.job', job.get('plainId'));
|
||||
this.transitionToRoute('jobs.job', job.get('plainId'), {
|
||||
queryParams: { namespace: job.get('namespace.name') },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
10
ui/app/controllers/jobs/job.js
Normal file
10
ui/app/controllers/jobs/job.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class JobController extends Controller {
|
||||
queryParams = [
|
||||
{
|
||||
jobNamespace: 'namespace',
|
||||
},
|
||||
];
|
||||
jobNamespace = 'default';
|
||||
}
|
||||
@@ -19,9 +19,9 @@ export default class DefinitionController extends Controller.extend(WithNamespac
|
||||
this.set('isEditing', false);
|
||||
}
|
||||
|
||||
onSubmit(id, namespace) {
|
||||
onSubmit(id, jobNamespace) {
|
||||
this.transitionToRoute('jobs.job', id, {
|
||||
queryParams: { jobNamespace: namespace },
|
||||
queryParams: { jobNamespace },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Controller from '@ember/controller';
|
||||
export default class RunController extends Controller {
|
||||
onSubmit(id, namespace) {
|
||||
this.transitionToRoute('jobs.job', id, {
|
||||
queryParams: { jobNamespace: namespace },
|
||||
queryParams: { namespace },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ export default class OptimizeController extends Controller {
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
includeAllNamespaces: 'all-namespaces',
|
||||
searchTerm: 'search',
|
||||
},
|
||||
{
|
||||
searchTerm: 'search',
|
||||
qpNamespace: 'namespacefilter',
|
||||
},
|
||||
{
|
||||
qpType: 'type',
|
||||
@@ -48,21 +48,53 @@ export default class OptimizeController extends Controller {
|
||||
});
|
||||
}
|
||||
|
||||
get namespaces() {
|
||||
return this.model.namespaces;
|
||||
}
|
||||
|
||||
get summaries() {
|
||||
return this.model.summaries;
|
||||
}
|
||||
|
||||
@tracked searchTerm = '';
|
||||
|
||||
@tracked qpType = '';
|
||||
@tracked qpStatus = '';
|
||||
@tracked qpDatacenter = '';
|
||||
@tracked qpPrefix = '';
|
||||
|
||||
@tracked includeAllNamespaces = true;
|
||||
@tracked qpNamespace = '*';
|
||||
|
||||
@selection('qpType') selectionType;
|
||||
@selection('qpStatus') selectionStatus;
|
||||
@selection('qpDatacenter') selectionDatacenter;
|
||||
@selection('qpPrefix') selectionPrefix;
|
||||
|
||||
optionsType = [{ key: 'service', label: 'Service' }, { key: 'system', label: 'System' }];
|
||||
get optionsNamespaces() {
|
||||
const availableNamespaces = this.namespaces.map(namespace => ({
|
||||
key: namespace.name,
|
||||
label: namespace.name,
|
||||
}));
|
||||
|
||||
availableNamespaces.unshift({
|
||||
key: '*',
|
||||
label: 'All (*)',
|
||||
});
|
||||
|
||||
// Unset the namespace selection if it was server-side deleted
|
||||
if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) {
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.qpNamespace = this.system.cachedNamespace || 'default';
|
||||
});
|
||||
}
|
||||
|
||||
return availableNamespaces;
|
||||
}
|
||||
|
||||
optionsType = [
|
||||
{ key: 'service', label: 'Service' },
|
||||
{ key: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
optionsStatus = [
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
@@ -72,7 +104,7 @@ export default class OptimizeController extends Controller {
|
||||
|
||||
get optionsDatacenter() {
|
||||
const flatten = (acc, val) => acc.concat(val);
|
||||
const allDatacenters = new Set(this.model.mapBy('job.datacenters').reduce(flatten, []));
|
||||
const allDatacenters = new Set(this.summaries.mapBy('job.datacenters').reduce(flatten, []));
|
||||
|
||||
// Remove any invalid datacenters from the query param/selection
|
||||
const availableDatacenters = Array.from(allDatacenters).compact();
|
||||
@@ -90,7 +122,7 @@ export default class OptimizeController extends Controller {
|
||||
const hasPrefix = /.[-._]/;
|
||||
|
||||
// Collect and count all the prefixes
|
||||
const allNames = this.model.mapBy('job.name');
|
||||
const allNames = this.summaries.mapBy('job.name');
|
||||
const nameHistogram = allNames.reduce((hist, name) => {
|
||||
if (hasPrefix.test(name)) {
|
||||
const prefix = name.match(/(.+?)[-._]/)[1];
|
||||
@@ -130,9 +162,6 @@ export default class OptimizeController extends Controller {
|
||||
selectionPrefix: prefixes,
|
||||
} = this;
|
||||
|
||||
const shouldShowNamespaces = this.system.shouldShowNamespaces;
|
||||
const activeNamespace = shouldShowNamespaces ? this.system.activeNamespace.name : undefined;
|
||||
|
||||
// A summary’s job must match ALL filter facets, but it can match ANY selection within a facet
|
||||
// Always return early to prevent unnecessary facet predicates.
|
||||
return this.summarySearch.listSearched.filter(summary => {
|
||||
@@ -142,11 +171,7 @@ export default class OptimizeController extends Controller {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldShowNamespaces &&
|
||||
!this.includeAllNamespaces &&
|
||||
activeNamespace !== summary.jobNamespace
|
||||
) {
|
||||
if (this.qpNamespace !== '*' && job.get('namespace.name') !== this.qpNamespace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -201,14 +226,13 @@ export default class OptimizeController extends Controller {
|
||||
}
|
||||
|
||||
@action
|
||||
setFacetQueryParam(queryParam, selection) {
|
||||
this[queryParam] = serialize(selection);
|
||||
this.syncActiveSummary();
|
||||
cacheNamespace(namespace) {
|
||||
this.system.cachedNamespace = namespace;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleIncludeAllNamespaces() {
|
||||
this.includeAllNamespaces = !this.includeAllNamespaces;
|
||||
setFacetQueryParam(queryParam, selection) {
|
||||
this[queryParam] = serialize(selection);
|
||||
this.syncActiveSummary();
|
||||
}
|
||||
|
||||
@@ -238,7 +262,7 @@ class RecommendationSummarySearch extends EmberObject.extend(Searchable) {
|
||||
return ['slug'];
|
||||
}
|
||||
|
||||
@alias('dataSource.model') listToSearch;
|
||||
@alias('dataSource.summaries') listToSearch;
|
||||
@alias('dataSource.searchTerm') searchTerm;
|
||||
|
||||
exactMatchEnabled = false;
|
||||
|
||||
@@ -9,7 +9,6 @@ import classic from 'ember-classic-decorator';
|
||||
@classic
|
||||
export default class Tokens extends Controller {
|
||||
@service token;
|
||||
@service system;
|
||||
@service store;
|
||||
|
||||
@reads('token.secret') secret;
|
||||
@@ -32,7 +31,6 @@ export default class Tokens extends Controller {
|
||||
tokenIsInvalid: false,
|
||||
});
|
||||
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
|
||||
this.system.reset();
|
||||
this.resetStore();
|
||||
this.token.reset();
|
||||
}
|
||||
@@ -47,7 +45,6 @@ export default class Tokens extends Controller {
|
||||
TokenAdapter.findSelf().then(
|
||||
() => {
|
||||
// Clear out all data to ensure only data the new token is privileged to see is shown
|
||||
this.system.reset();
|
||||
this.resetStore();
|
||||
|
||||
// Refetch the token and associated policies
|
||||
|
||||
@@ -12,7 +12,6 @@ export default Mixin.create({
|
||||
// Since the setupController hook doesn't fire when transitioning up the
|
||||
// route hierarchy, the two sides of the namespace bindings need to be manipulated
|
||||
// in order for the jobs route model to reload.
|
||||
this.set('system.activeNamespace', this.get('jobsController.jobNamespace'));
|
||||
this.set('jobsController.jobNamespace', namespace.get('id'));
|
||||
this.transitionToRoute('jobs');
|
||||
},
|
||||
|
||||
@@ -80,7 +80,6 @@ export default class ApplicationRoute extends Route {
|
||||
(queryParam && queryParam !== currentRegion) ||
|
||||
(!queryParam && currentRegion !== defaultRegion)
|
||||
) {
|
||||
this.system.reset();
|
||||
this.store.unloadAll();
|
||||
}
|
||||
|
||||
@@ -91,7 +90,14 @@ export default class ApplicationRoute extends Route {
|
||||
|
||||
// Model is being used as a way to propagate the region and
|
||||
// one time token query parameters for use in setupController.
|
||||
model({ region }, { to: { queryParams: { ott }}}) {
|
||||
model(
|
||||
{ region },
|
||||
{
|
||||
to: {
|
||||
queryParams: { ott },
|
||||
},
|
||||
}
|
||||
) {
|
||||
return {
|
||||
region,
|
||||
hasOneTimeToken: ott,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Route from '@ember/routing/route';
|
||||
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class VolumesRoute extends Route.extend(WithForbiddenState) {
|
||||
export default class VolumesRoute extends Route.extend() {
|
||||
@service system;
|
||||
@service store;
|
||||
|
||||
@@ -15,29 +13,4 @@ export default class VolumesRoute extends Route.extend(WithForbiddenState) {
|
||||
args: ['csi.index'],
|
||||
},
|
||||
];
|
||||
|
||||
queryParams = {
|
||||
volumeNamespace: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeModel(transition) {
|
||||
return this.get('system.namespaces').then(namespaces => {
|
||||
const queryParam = transition.to.queryParams.namespace;
|
||||
this.set('system.activeNamespace', queryParam || 'default');
|
||||
|
||||
return namespaces;
|
||||
});
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store
|
||||
.query('volume', { type: 'csi' })
|
||||
.then(volumes => {
|
||||
volumes.forEach(volume => volume.plugin);
|
||||
return volumes;
|
||||
})
|
||||
.catch(notifyForbidden(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import RSVP from 'rsvp';
|
||||
import Route from '@ember/routing/route';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import { watchQuery } from 'nomad-ui/utils/properties/watch';
|
||||
import { watchQuery, watchAll } from 'nomad-ui/utils/properties/watch';
|
||||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
|
||||
export default class IndexRoute extends Route.extend(WithWatchers) {
|
||||
startWatchers(controller) {
|
||||
controller.set('modelWatch', this.watch.perform({ type: 'csi' }));
|
||||
export default class IndexRoute extends Route.extend(WithWatchers, WithForbiddenState) {
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
qpNamespace: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
model(params) {
|
||||
return RSVP.hash({
|
||||
volumes: this.store
|
||||
.query('volume', { type: 'csi', namespace: params.qpNamespace })
|
||||
.catch(notifyForbidden(this)),
|
||||
namespaces: this.store.findAll('namespace'),
|
||||
});
|
||||
}
|
||||
|
||||
@watchQuery('volume') watch;
|
||||
@collect('watch') watchers;
|
||||
startWatchers(controller) {
|
||||
controller.set('namespacesWatch', this.watchNamespaces.perform());
|
||||
controller.set(
|
||||
'modelWatch',
|
||||
this.watchVolumes.perform({ type: 'csi', namespace: controller.qpNamespace })
|
||||
);
|
||||
}
|
||||
|
||||
@watchQuery('volume') watchVolumes;
|
||||
@watchAll('namespace') watchNamespaces;
|
||||
@collect('watchVolumes', 'watchNamespaces') watchers;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Route from '@ember/routing/route';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import RSVP from 'rsvp';
|
||||
import notifyError from 'nomad-ui/utils/notify-error';
|
||||
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
|
||||
import { watchRecord } from 'nomad-ui/utils/properties/watch';
|
||||
@@ -43,10 +44,15 @@ export default class VolumeRoute extends Route.extend(WithWatchers) {
|
||||
}
|
||||
|
||||
model(params, transition) {
|
||||
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
|
||||
const namespace = transition.to.queryParams.namespace;
|
||||
const name = params.volume_name;
|
||||
const fullId = JSON.stringify([`csi/${name}`, namespace || 'default']);
|
||||
return this.store.findRecord('volume', fullId, { reload: true }).catch(notifyError(this));
|
||||
return RSVP.hash({
|
||||
volume: this.store.findRecord('volume', fullId, { reload: true }),
|
||||
namespaces: this.store.findAll('namespace'),
|
||||
})
|
||||
.then(hash => hash.volume)
|
||||
.catch(notifyError(this));
|
||||
}
|
||||
|
||||
// Since volume includes embedded records for allocations,
|
||||
|
||||
@@ -16,7 +16,7 @@ export default class ExecRoute extends Route.extend(WithWatchers) {
|
||||
}
|
||||
|
||||
model(params, transition) {
|
||||
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
|
||||
const namespace = transition.to.queryParams.namespace;
|
||||
const name = params.job_name;
|
||||
const fullId = JSON.stringify([name, namespace || 'default']);
|
||||
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Route from '@ember/routing/route';
|
||||
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
import { action } from '@ember/object';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class JobsRoute extends Route.extend(WithForbiddenState) {
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
export default class JobsRoute extends Route.extend() {
|
||||
breadcrumbs = [
|
||||
{
|
||||
label: 'Jobs',
|
||||
args: ['jobs.index'],
|
||||
},
|
||||
];
|
||||
|
||||
queryParams = {
|
||||
jobNamespace: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeModel(transition) {
|
||||
return this.get('system.namespaces').then(namespaces => {
|
||||
const queryParam = transition.to.queryParams.namespace;
|
||||
this.set('system.activeNamespace', queryParam || 'default');
|
||||
|
||||
return namespaces;
|
||||
});
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.findAll('job', { reload: true }).catch(notifyForbidden(this));
|
||||
}
|
||||
|
||||
@action
|
||||
refreshRoute() {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Route from '@ember/routing/route';
|
||||
import RSVP from 'rsvp';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import { watchAll } from 'nomad-ui/utils/properties/watch';
|
||||
import { watchAll, watchQuery } from 'nomad-ui/utils/properties/watch';
|
||||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||
|
||||
export default class IndexRoute extends Route.extend(WithWatchers) {
|
||||
startWatchers(controller) {
|
||||
controller.set('modelWatch', this.watch.perform());
|
||||
export default class IndexRoute extends Route.extend(WithWatchers, WithForbiddenState) {
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
qpNamespace: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
model(params) {
|
||||
return RSVP.hash({
|
||||
jobs: this.store.query('job', { namespace: params.qpNamespace }).catch(notifyForbidden(this)),
|
||||
namespaces: this.store.findAll('namespace'),
|
||||
});
|
||||
}
|
||||
|
||||
@watchAll('job') watch;
|
||||
@collect('watch') watchers;
|
||||
startWatchers(controller) {
|
||||
controller.set('namespacesWatch', this.watchNamespaces.perform());
|
||||
controller.set('modelWatch', this.watchJobs.perform({ namespace: controller.qpNamesapce }));
|
||||
}
|
||||
|
||||
@watchQuery('job') watchJobs;
|
||||
@watchAll('namespace') watchNamespaces;
|
||||
@collect('watchJobs', 'watchNamespaces') watchers;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ export default class JobRoute extends Route {
|
||||
}
|
||||
|
||||
model(params, transition) {
|
||||
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
|
||||
const namespace = transition.to.queryParams.namespace || 'default';
|
||||
const name = params.job_name;
|
||||
const fullId = JSON.stringify([name, namespace || 'default']);
|
||||
const fullId = JSON.stringify([name, namespace]);
|
||||
|
||||
return this.store
|
||||
.findRecord('job', fullId, { reload: true })
|
||||
@@ -28,6 +28,8 @@ export default class JobRoute extends Route {
|
||||
const relatedModelsQueries = [
|
||||
job.get('allocations'),
|
||||
job.get('evaluations'),
|
||||
this.store.query('job', { namespace }),
|
||||
this.store.findAll('namespace'),
|
||||
];
|
||||
|
||||
if (this.can.can('accept recommendation')) {
|
||||
|
||||
@@ -22,8 +22,10 @@ export default class RunRoute extends Route {
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.createRecord('job', {
|
||||
namespace: this.get('system.activeNamespace'),
|
||||
// When jobs are created with a namespace attribute, it is verified against
|
||||
// available namespaces to prevent redirecting to a non-existent namespace.
|
||||
return this.store.findAll('namespace').then(() => {
|
||||
return this.store.createRecord('job');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,18 @@ export default class OptimizeRoute extends Route {
|
||||
async model() {
|
||||
const summaries = await this.store.findAll('recommendation-summary');
|
||||
const jobs = await RSVP.all(summaries.mapBy('job'));
|
||||
await RSVP.all(
|
||||
jobs
|
||||
const [namespaces] = await RSVP.all([
|
||||
this.store.findAll('namespace'),
|
||||
...jobs
|
||||
.filter(job => job)
|
||||
.filterBy('isPartial')
|
||||
.map(j => j.reload())
|
||||
);
|
||||
.map(j => j.reload()),
|
||||
]);
|
||||
|
||||
return summaries.sortBy('submitTime').reverse();
|
||||
return {
|
||||
summaries: summaries.sortBy('submitTime').reverse(),
|
||||
namespaces,
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -9,7 +9,9 @@ export default class OptimizeIndexRoute extends Route {
|
||||
const firstSummary = summaries.objectAt(0);
|
||||
|
||||
return this.transitionTo('optimize.summary', firstSummary.slug, {
|
||||
queryParams: { jobNamespace: firstSummary.jobNamespace || 'default' },
|
||||
queryParams: {
|
||||
jobNamespace: firstSummary.jobNamespace || 'default',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default class OptimizeSummaryRoute extends Route {
|
||||
}
|
||||
|
||||
async model({ jobNamespace, slug }) {
|
||||
const model = this.modelFor('optimize').find(
|
||||
const model = this.modelFor('optimize').summaries.find(
|
||||
summary => summary.slug === slug && summary.jobNamespace === jobNamespace
|
||||
);
|
||||
|
||||
|
||||
@@ -52,11 +52,7 @@ export default class AllocationSerializer extends ApplicationSerializer {
|
||||
hash.JobVersion = hash.JobVersion != null ? hash.JobVersion : get(hash, 'Job.Version');
|
||||
|
||||
hash.PlainJobId = hash.JobID;
|
||||
hash.Namespace =
|
||||
hash.Namespace ||
|
||||
get(hash, 'Job.Namespace') ||
|
||||
this.get('system.activeNamespace.id') ||
|
||||
'default';
|
||||
hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default';
|
||||
hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]);
|
||||
|
||||
hash.RescheduleEvents = (hash.RescheduleTracker || {}).Events;
|
||||
|
||||
@@ -14,11 +14,7 @@ export default class DeploymentSerializer extends ApplicationSerializer {
|
||||
normalize(typeHash, hash) {
|
||||
if (hash) {
|
||||
hash.PlainJobId = hash.JobID;
|
||||
hash.Namespace =
|
||||
hash.Namespace ||
|
||||
get(hash, 'Job.Namespace') ||
|
||||
this.get('system.activeNamespace.id') ||
|
||||
'default';
|
||||
hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default';
|
||||
|
||||
// Ember Data doesn't support multiple inverses. This means that since jobs have
|
||||
// two relationships to a deployment (hasMany deployments, and belongsTo latestDeployment),
|
||||
|
||||
@@ -12,11 +12,7 @@ export default class Evaluation extends ApplicationSerializer {
|
||||
|
||||
normalize(typeHash, hash) {
|
||||
hash.PlainJobId = hash.JobID;
|
||||
hash.Namespace =
|
||||
hash.Namespace ||
|
||||
get(hash, 'Job.Namespace') ||
|
||||
this.get('system.activeNamespace.id') ||
|
||||
'default';
|
||||
hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default';
|
||||
hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]);
|
||||
|
||||
return super.normalize(typeHash, hash);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { computed } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import PromiseObject from '../utils/classes/promise-object';
|
||||
import PromiseArray from '../utils/classes/promise-array';
|
||||
@@ -113,36 +114,11 @@ export default class SystemService extends Service {
|
||||
return namespaces.length && namespaces.some(namespace => namespace.get('id') !== 'default');
|
||||
}
|
||||
|
||||
@computed('namespaces.[]')
|
||||
get activeNamespace() {
|
||||
const namespaceId = window.localStorage.nomadActiveNamespace || 'default';
|
||||
const namespace = this.namespaces.findBy('id', namespaceId);
|
||||
|
||||
if (namespace) {
|
||||
return namespace;
|
||||
}
|
||||
|
||||
// If the namespace in localStorage is no longer in the cluster, it needs to
|
||||
// be cleared from localStorage
|
||||
window.localStorage.removeItem('nomadActiveNamespace');
|
||||
return this.namespaces.findBy('id', 'default');
|
||||
}
|
||||
|
||||
set activeNamespace(value) {
|
||||
if (value == null) {
|
||||
window.localStorage.removeItem('nomadActiveNamespace');
|
||||
return;
|
||||
} else if (typeof value === 'string') {
|
||||
window.localStorage.nomadActiveNamespace = value;
|
||||
} else {
|
||||
window.localStorage.nomadActiveNamespace = value.get('name');
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.set('activeNamespace', null);
|
||||
this.notifyPropertyChange('namespaces');
|
||||
}
|
||||
// The cachedNamespace is set on pages that have a namespaces filter.
|
||||
// It is set so other pages that have a namespaces filter can default to
|
||||
// what the previous namespaces filter page used rather than defaulting
|
||||
// to 'default' or '*'.
|
||||
@tracked cachedNamespace = null;
|
||||
|
||||
@task(function*() {
|
||||
const emptyLicense = { License: { Features: [] } };
|
||||
|
||||
@@ -161,33 +161,47 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
// ember-power-select@v4.1.3: There is currently no way to set a class
|
||||
// on an individual option, so .ember-power-select-option must be included
|
||||
// in these selectors.
|
||||
.dropdown-option,
|
||||
.ember-power-select-option {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 1.75;
|
||||
|
||||
label {
|
||||
label,
|
||||
.dropdown-label {
|
||||
display: block;
|
||||
padding: 3px 8px;
|
||||
min-width: 100px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
& + .dropdown-option {
|
||||
& + .dropdown-option,
|
||||
& + .ember-power-select-option {
|
||||
border-top: 1px solid $grey-lighter;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&:focus,
|
||||
&[aria-current='true'],
|
||||
&[aria-selected='true'] {
|
||||
background: $white-bis;
|
||||
color: $black;
|
||||
outline: none;
|
||||
border-left: 2px solid $blue;
|
||||
label {
|
||||
label,
|
||||
.dropdown-label {
|
||||
padding-left: 6px;
|
||||
min-width: 98px;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: $blue-050;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
}
|
||||
|
||||
.annotation {
|
||||
padding: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -186,4 +187,8 @@
|
||||
color: $grey;
|
||||
font-weight: $weight-bold;
|
||||
}
|
||||
|
||||
.title:not(:first-child) {
|
||||
margin-top: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ $blue-500: #1563ff;
|
||||
$blue-400: #387aff;
|
||||
$blue-300: #5b92ff;
|
||||
$blue-200: #8ab1ff;
|
||||
$blue-100: #bfd4ff;
|
||||
$blue-050: #f0f5ff;
|
||||
|
||||
$teal-500: #25ba81;
|
||||
$teal-300: #74d3ae;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{#let (cannot "exec allocation") as |cannotExec|}}
|
||||
{{#let (cannot "exec allocation" namespace=this.job.namespace) as |cannotExec|}}
|
||||
<button
|
||||
data-test-exec-button
|
||||
type="button"
|
||||
@@ -9,4 +9,5 @@
|
||||
{{x-icon "console"}}
|
||||
<span>Exec</span>
|
||||
</button>
|
||||
{{/let}}
|
||||
{{/let}}
|
||||
|
||||
|
||||
@@ -23,27 +23,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<div class="menu-item is-wide" data-test-namespace-switcher-parent>
|
||||
<PowerSelect
|
||||
data-test-namespace-switcher
|
||||
@options={{this.sortedNamespaces}}
|
||||
@selected={{this.system.activeNamespace}}
|
||||
@searchField="name"
|
||||
@searchEnabled={{gt this.sortedNamespaces.length 10}}
|
||||
@onChange={{action this.gotoJobsForNamespace}}
|
||||
@tagName="div"
|
||||
@class="namespace-switcher"
|
||||
title={{this.system.activeNamespace.name}}
|
||||
as |namespace|>
|
||||
<span class="ember-power-select-prefix">Namespace: </span>{{namespace.name}}
|
||||
</PowerSelect>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{/if}}
|
||||
<p class="menu-label">
|
||||
Workload
|
||||
</p>
|
||||
@@ -51,7 +30,6 @@
|
||||
<li>
|
||||
<LinkTo
|
||||
@route="jobs"
|
||||
@query={{hash jobNamespace=this.system.activeNamespace.id}}
|
||||
@activeClass="is-active"
|
||||
data-test-gutter-link="jobs">
|
||||
Jobs
|
||||
@@ -75,7 +53,6 @@
|
||||
<li>
|
||||
<LinkTo
|
||||
@route="csi"
|
||||
@query={{hash volumeNamespace=this.system.activeNamespace.id}}
|
||||
@activeClass="is-active"
|
||||
data-test-gutter-link="storage">
|
||||
Storage <span class="tag is-small">Beta</span>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<span data-test-job-stat="priority"><strong>Priority:</strong> {{this.job.priority}} </span>
|
||||
<span data-test-job-stat="parent">
|
||||
<strong>Parent:</strong>
|
||||
<LinkTo @route="jobs.job" @model={{this.job.parent}} @query={{hash jobNamespace=this.job.parent.namespace.name}}>
|
||||
<LinkTo @route="jobs.job" @model={{this.job.parent}} @query={{hash namespace=this.job.parent.namespace.name}}>
|
||||
{{this.job.parent.name}}
|
||||
</LinkTo>
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} class="is-primary">{{this.job.name}}</LinkTo></td>
|
||||
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} @query={{hash namespace=this.job.namespace.id}} class="is-primary">{{this.job.name}}</LinkTo></td>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<td data-test-job-namespace>{{this.job.namespace.name}}</td>
|
||||
{{/if}}
|
||||
{{#if (eq @context "child")}}
|
||||
<td data-test-job-submit-time>{{format-month-ts this.job.submitTime}}</td>
|
||||
{{/if}}
|
||||
|
||||
@@ -6,20 +6,35 @@
|
||||
</ul>
|
||||
</div>
|
||||
<section class="section">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.visibleVolumes.length}}
|
||||
<SearchBox
|
||||
data-test-volumes-search
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search volumes..." />
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
<SingleSelectDropdown
|
||||
data-test-namespace-facet
|
||||
@label="Namespace"
|
||||
@options={{this.optionsNamespaces}}
|
||||
@selection={{this.qpNamespace}}
|
||||
@onSelect={{action (queue
|
||||
(action this.cacheNamespace)
|
||||
(action this.setFacetQueryParam "qpNamespace")
|
||||
)}} />
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.isForbidden}}
|
||||
<ForbiddenMessage />
|
||||
{{else}}
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.model.length}}
|
||||
<SearchBox
|
||||
data-test-volumes-search
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search volumes..." />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.sortedVolumes}}
|
||||
<ListPagination
|
||||
@source={{this.sortedVolumes}}
|
||||
@@ -32,6 +47,9 @@
|
||||
@class="with-foot" as |t|>
|
||||
<t.head>
|
||||
<t.sort-by @prop="name">Name</t.sort-by>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<t.sort-by @prop="namespace.name">Namespace</t.sort-by>
|
||||
{{/if}}
|
||||
<t.sort-by @prop="schedulable">Volume Health</t.sort-by>
|
||||
<t.sort-by @prop="controllersHealthyProportion">Controller Health</t.sort-by>
|
||||
<t.sort-by @prop="nodesHealthyProportion">Node Health</t.sort-by>
|
||||
@@ -41,8 +59,17 @@
|
||||
<t.body @key="model.name" as |row|>
|
||||
<tr class="is-interactive" data-test-volume-row {{on "click" (action "gotoVolume" row.model)}}>
|
||||
<td data-test-volume-name>
|
||||
<LinkTo @route="csi.volumes.volume" @model={{row.model.plainId}} class="is-primary">{{row.model.name}}</LinkTo>
|
||||
<LinkTo
|
||||
@route="csi.volumes.volume"
|
||||
@model={{row.model.plainId}}
|
||||
@query={{hash volumeNamespace=row.model.namespace.name}}
|
||||
class="is-primary">
|
||||
{{row.model.name}}
|
||||
</LinkTo>
|
||||
</td>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<td data-test-volume-namespace>{{row.model.namespace.name}}</td>
|
||||
{{/if}}
|
||||
<td data-test-volume-schedulable>{{if row.model.schedulable "Schedulable" "Unschedulable"}}</td>
|
||||
<td data-test-volume-controller-health>
|
||||
{{#if row.model.controllerRequired}}
|
||||
@@ -80,10 +107,10 @@
|
||||
</ListPagination>
|
||||
{{else}}
|
||||
<div data-test-empty-volumes-list class="empty-message">
|
||||
{{#if (eq this.model.length 0)}}
|
||||
{{#if (eq this.visibleVolumes.length 0)}}
|
||||
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">No Volumes</h3>
|
||||
<p class="empty-message-body">
|
||||
The cluster currently has no CSI Volumes.
|
||||
This namespace currently has no CSI Volumes.
|
||||
</p>
|
||||
{{else if this.searchTerm}}
|
||||
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">No Matches</h3>
|
||||
|
||||
@@ -1,77 +1,88 @@
|
||||
{{page-title "Jobs"}}
|
||||
<section class="section">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.visibleJobs.length}}
|
||||
<SearchBox
|
||||
data-test-jobs-search
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search jobs..." />
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if (media "isMobile")}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#if (can "run job")}}
|
||||
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
|
||||
{{else}}
|
||||
<button
|
||||
data-test-run-job
|
||||
class="button is-primary is-disabled tooltip is-right-aligned"
|
||||
aria-label="You don’t have permission to run jobs"
|
||||
disabled
|
||||
type="button"
|
||||
>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<SingleSelectDropdown
|
||||
data-test-namespace-facet
|
||||
@label="Namespace"
|
||||
@options={{this.optionsNamespaces}}
|
||||
@selection={{this.qpNamespace}}
|
||||
@onSelect={{action (queue
|
||||
(action this.cacheNamespace)
|
||||
(action this.setFacetQueryParam "qpNamespace")
|
||||
)}} />
|
||||
{{/if}}
|
||||
<MultiSelectDropdown
|
||||
data-test-type-facet
|
||||
@label="Type"
|
||||
@options={{this.optionsType}}
|
||||
@selection={{this.selectionType}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpType"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-status-facet
|
||||
@label="Status"
|
||||
@options={{this.optionsStatus}}
|
||||
@selection={{this.selectionStatus}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-datacenter-facet
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenter}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-prefix-facet
|
||||
@label="Prefix"
|
||||
@options={{this.optionsPrefix}}
|
||||
@selection={{this.selectionPrefix}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
|
||||
</div>
|
||||
</div>
|
||||
{{#if (not (media "isMobile"))}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#if (can "run job")}}
|
||||
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
|
||||
{{else}}
|
||||
<button
|
||||
data-test-run-job
|
||||
class="button is-primary is-disabled tooltip is-right-aligned"
|
||||
aria-label="You don’t have permission to run jobs"
|
||||
disabled
|
||||
type="button"
|
||||
>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.isForbidden}}
|
||||
<ForbiddenMessage />
|
||||
{{else}}
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.visibleJobs.length}}
|
||||
<SearchBox
|
||||
data-test-jobs-search
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@onChange={{action this.resetPagination}}
|
||||
@placeholder="Search jobs..." />
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if (media "isMobile")}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#if (can "run job")}}
|
||||
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
|
||||
{{else}}
|
||||
<button
|
||||
data-test-run-job
|
||||
class="button is-primary is-disabled tooltip is-right-aligned"
|
||||
aria-label="You don’t have permission to run jobs"
|
||||
disabled
|
||||
type="button"
|
||||
>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
<MultiSelectDropdown
|
||||
data-test-type-facet
|
||||
@label="Type"
|
||||
@options={{this.optionsType}}
|
||||
@selection={{this.selectionType}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpType"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-status-facet
|
||||
@label="Status"
|
||||
@options={{this.optionsStatus}}
|
||||
@selection={{this.selectionStatus}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-datacenter-facet
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenter}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
|
||||
<MultiSelectDropdown
|
||||
data-test-prefix-facet
|
||||
@label="Prefix"
|
||||
@options={{this.optionsPrefix}}
|
||||
@selection={{this.selectionPrefix}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
|
||||
</div>
|
||||
</div>
|
||||
{{#if (not (media "isMobile"))}}
|
||||
<div class="toolbar-item is-right-aligned">
|
||||
{{#if (can "run job")}}
|
||||
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
|
||||
{{else}}
|
||||
<button
|
||||
data-test-run-job
|
||||
class="button is-primary is-disabled tooltip is-right-aligned"
|
||||
aria-label="You don’t have permission to run jobs"
|
||||
disabled
|
||||
type="button"
|
||||
>Run Job</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.sortedJobs}}
|
||||
<ListPagination
|
||||
@source={{this.sortedJobs}}
|
||||
@@ -84,6 +95,9 @@
|
||||
@class="with-foot" as |t|>
|
||||
<t.head>
|
||||
<t.sort-by @prop="name">Name</t.sort-by>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<t.sort-by @prop="namespace.name">Namespace</t.sort-by>
|
||||
{{/if}}
|
||||
<t.sort-by @prop="status">Status</t.sort-by>
|
||||
<t.sort-by @prop="type">Type</t.sort-by>
|
||||
<t.sort-by @prop="priority">Priority</t.sort-by>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<PageLayout>
|
||||
<section class="section">
|
||||
{{#if @model}}
|
||||
{{#if this.summaries}}
|
||||
<div class="toolbar collapse">
|
||||
<div class="toolbar-item">
|
||||
{{#if @model}}
|
||||
{{#if this.summaries}}
|
||||
<SearchBox
|
||||
data-test-recommendation-summaries-search
|
||||
@onChange={{this.syncActiveSummary}}
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@placeholder="Search {{this.model.length}} {{pluralize "recommendation" this.model.length}}..." />
|
||||
@placeholder="Search {{this.summaries.length}} {{pluralize "recommendation" this.summaries.length}}..." />
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<SingleSelectDropdown
|
||||
data-test-namespace-facet
|
||||
@label="Namespace"
|
||||
@options={{this.optionsNamespaces}}
|
||||
@selection={{this.qpNamespace}}
|
||||
@onSelect={{action (queue
|
||||
(action this.cacheNamespace)
|
||||
(action this.setFacetQueryParam "qpNamespace")
|
||||
)}} />
|
||||
{{/if}}
|
||||
<MultiSelectDropdown
|
||||
data-test-type-facet
|
||||
@label="Type"
|
||||
@@ -40,20 +51,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<Toggle
|
||||
data-test-all-namespaces-toggle
|
||||
@class="is-flex"
|
||||
@isActive={{this.includeAllNamespaces}}
|
||||
@onToggle={{this.toggleIncludeAllNamespaces}}
|
||||
>
|
||||
<div class="is-size-7 label">Include all namespaces</div>
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.filteredSummaries}}
|
||||
{{outlet}}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { computed } from '@ember/object';
|
||||
|
||||
// An unattractive but robust way to encode query params
|
||||
export const serialize = arr => (arr.length ? JSON.stringify(arr) : '');
|
||||
export const serialize = val => {
|
||||
if (typeof val === 'string' || typeof val === 'number') return val;
|
||||
return val.length ? JSON.stringify(val) : '';
|
||||
};
|
||||
|
||||
export const deserialize = str => {
|
||||
try {
|
||||
|
||||
@@ -57,11 +57,12 @@ export default function() {
|
||||
const json = this.serialize(jobs.all());
|
||||
const namespace = queryParams.namespace || 'default';
|
||||
return json
|
||||
.filter(job =>
|
||||
namespace === 'default'
|
||||
.filter(job => {
|
||||
if (namespace === '*') return true;
|
||||
return namespace === 'default'
|
||||
? !job.NamespaceID || job.NamespaceID === namespace
|
||||
: job.NamespaceID === namespace
|
||||
)
|
||||
: job.NamespaceID === namespace;
|
||||
})
|
||||
.map(job => filterKeys(job, 'TaskGroups', 'NamespaceID'));
|
||||
})
|
||||
);
|
||||
@@ -262,29 +263,33 @@ export default function() {
|
||||
|
||||
const json = this.serialize(csiVolumes.all());
|
||||
const namespace = queryParams.namespace || 'default';
|
||||
return json.filter(volume =>
|
||||
namespace === 'default'
|
||||
return json.filter(volume => {
|
||||
if (namespace === '*') return true;
|
||||
return namespace === 'default'
|
||||
? !volume.NamespaceID || volume.NamespaceID === namespace
|
||||
: volume.NamespaceID === namespace
|
||||
);
|
||||
: volume.NamespaceID === namespace;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
this.get(
|
||||
'/volume/:id',
|
||||
withBlockingSupport(function({ csiVolumes }, { params }) {
|
||||
withBlockingSupport(function({ csiVolumes }, { params, queryParams }) {
|
||||
if (!params.id.startsWith('csi/')) {
|
||||
return new Response(404, {}, null);
|
||||
}
|
||||
|
||||
const id = params.id.replace(/^csi\//, '');
|
||||
const volume = csiVolumes.find(id);
|
||||
const volume = csiVolumes.all().models.find(volume => {
|
||||
const volumeIsDefault = !volume.namespaceId || volume.namespaceId === 'default';
|
||||
const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default';
|
||||
return (
|
||||
volume.id === id &&
|
||||
(volume.namespaceId === queryParams.namespace || (volumeIsDefault && qpIsDefault))
|
||||
);
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
return new Response(404, {}, null);
|
||||
}
|
||||
|
||||
return this.serialize(volume);
|
||||
return volume ? this.serialize(volume) : new Response(404, {}, null);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -318,15 +323,11 @@ export default function() {
|
||||
return this.serialize(records);
|
||||
}
|
||||
|
||||
return new Response(501, {}, null);
|
||||
return this.serialize([{ Name: 'default' }]);
|
||||
});
|
||||
|
||||
this.get('/namespace/:id', function({ namespaces }, { params }) {
|
||||
if (namespaces.all().length) {
|
||||
return this.serialize(namespaces.find(params.id));
|
||||
}
|
||||
|
||||
return new Response(501, {}, null);
|
||||
return this.serialize(namespaces.find(params.id));
|
||||
});
|
||||
|
||||
this.get('/agent/members', function({ agents, regions }) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function(server) {
|
||||
// Scenarios
|
||||
|
||||
function smallCluster(server) {
|
||||
server.create('feature', { name: 'Dynamic Application Sizing' });
|
||||
server.createList('agent', 3);
|
||||
server.createList('node', 5);
|
||||
server.createList('job', 5, { createRecommendations: true });
|
||||
|
||||
207
ui/stories/components/filter-facets.stories.js
Normal file
207
ui/stories/components/filter-facets.stories.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
export default {
|
||||
title: 'Components|Filter Facets',
|
||||
};
|
||||
|
||||
let options1 = [
|
||||
{ key: 'option-1', label: 'Option One' },
|
||||
{ key: 'option-2', label: 'Option Two' },
|
||||
{ key: 'option-3', label: 'Option Three' },
|
||||
{ key: 'option-4', label: 'Option Four' },
|
||||
{ key: 'option-5', label: 'Option Five' },
|
||||
];
|
||||
|
||||
let selection1 = ['option-2', 'option-4', 'option-5'];
|
||||
|
||||
export let MultiSelect = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown</h5>
|
||||
<MultiSelectDropdown
|
||||
@label="Example Dropdown"
|
||||
@options={{this.options1}}
|
||||
@selection={{this.selection1}}
|
||||
@onSelect={{action (mut selection1)}} />
|
||||
<p class="annotation">A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.</p>
|
||||
`,
|
||||
context: {
|
||||
options1,
|
||||
selection1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let SingleSelect = () => ({
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Single-Select Dropdown</h5>
|
||||
<SingleSelectDropdown
|
||||
@label="Single"
|
||||
@options={{this.options1}}
|
||||
@selection={{this.selection}}
|
||||
@onSelect={{action (mut this.selection)}} />
|
||||
`,
|
||||
context: {
|
||||
options1,
|
||||
selection: 'option-2',
|
||||
},
|
||||
});
|
||||
|
||||
export let RightAligned = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown right-aligned</h5>
|
||||
<div style="display:flex; justify-content:flex-end">
|
||||
<MultiSelectDropdown
|
||||
@label="Example right-aligned Dropdown"
|
||||
@options={{this.options1}}
|
||||
@selection={{this.selection1}}
|
||||
@onSelect={{action (mut selection1)}} />
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
options1,
|
||||
selection1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let ManyOptionsMulti = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown with many options</h5>
|
||||
<MultiSelectDropdown
|
||||
@label="Lots of options in here"
|
||||
@options={{this.optionsMany}}
|
||||
@selection={{this.selectionMany}}
|
||||
@onSelect={{action (mut this.selectionMany)}} />
|
||||
<p class="annotation">
|
||||
A strength of the multi-select-dropdown is its simple presentation. It is quick to select options and it is quick to remove options.
|
||||
However, this strength becomes a weakness when there are too many options. Since the selection isn't pinned in any way, removing a selection
|
||||
can become an adventure of scrolling up and down. Also since the selection isn't pinned, this component can't support search, since search would
|
||||
entirely mask the selection.
|
||||
</p>
|
||||
`,
|
||||
context: {
|
||||
optionsMany: Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
|
||||
selectionMany: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let ManyOptionsSingle = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Single-Select Dropdown with many options</h5>
|
||||
<SingleSelectDropdown
|
||||
@label="Lots of options in here"
|
||||
@options={{this.optionsMany}}
|
||||
@selection={{this.selection}}
|
||||
@onSelect={{action (mut this.selection)}} />
|
||||
<p class="annotation">
|
||||
Single select supports search at a certain option threshold via Ember Power Select.
|
||||
</p>
|
||||
`,
|
||||
context: {
|
||||
optionsMany: Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
|
||||
selection: 'option-1',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let Bar = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown bar</h5>
|
||||
<div class="button-bar">
|
||||
<MultiSelectDropdown
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenter}}
|
||||
@onSelect={{action (mut this.selectionDatacenter)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Type"
|
||||
@options={{this.optionsType}}
|
||||
@selection={{this.selectionType}}
|
||||
@onSelect={{action (mut this.selectionType)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Status"
|
||||
@options={{this.optionsStatus}}
|
||||
@selection={{this.selectionStatus}}
|
||||
@onSelect={{action (mut this.selectionStatus)}} />
|
||||
</div>
|
||||
<h5 class="title is-5">Single-Select Dropdown bar</h5>
|
||||
<div class="button-bar">
|
||||
<SingleSelectDropdown
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenterSingle}}
|
||||
@onSelect={{action (mut this.selectionDatacenterSingle)}} />
|
||||
<SingleSelectDropdown
|
||||
@label="Type"
|
||||
@options={{this.optionsType}}
|
||||
@selection={{this.selectionTypeSingle}}
|
||||
@onSelect={{action (mut this.selectionTypeSingle)}} />
|
||||
<SingleSelectDropdown
|
||||
@label="Status"
|
||||
@options={{this.optionsStatus}}
|
||||
@selection={{this.selectionStatusSingle}}
|
||||
@onSelect={{action (mut this.selectionStatusSingle)}} />
|
||||
</div>
|
||||
<h5 class="title is-5">Mixed Dropdown bar</h5>
|
||||
<div class="button-bar">
|
||||
<SingleSelectDropdown
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenterSingle}}
|
||||
@onSelect={{action (mut this.selectionDatacenterSingle)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Type"
|
||||
@options={{this.optionsType}}
|
||||
@selection={{this.selectionType}}
|
||||
@onSelect={{action (mut this.selectionType)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Status"
|
||||
@options={{this.optionsStatus}}
|
||||
@selection={{this.selectionStatus}}
|
||||
@onSelect={{action (mut this.selectionStatus)}} />
|
||||
</div>
|
||||
<p class="annotation">
|
||||
Since this is a core component for faceted search, it makes sense to letruct an arrangement of multi-select dropdowns.
|
||||
Do this by wrapping all the options in a <code>.button-bar</code> container.
|
||||
</p>
|
||||
`,
|
||||
context: {
|
||||
optionsDatacenter: [
|
||||
{ key: 'pdx-1', label: 'pdx-1' },
|
||||
{ key: 'jfk-1', label: 'jfk-1' },
|
||||
{ key: 'jfk-2', label: 'jfk-2' },
|
||||
{ key: 'muc-1', label: 'muc-1' },
|
||||
],
|
||||
selectionDatacenter: ['jfk-1'],
|
||||
selectionDatacenterSingle: 'jfk-1',
|
||||
|
||||
optionsType: [
|
||||
{ key: 'batch', label: 'Batch' },
|
||||
{ key: 'service', label: 'Service' },
|
||||
{ key: 'system', label: 'System' },
|
||||
{ key: 'periodic', label: 'Periodic' },
|
||||
{ key: 'parameterized', label: 'Parameterized' },
|
||||
],
|
||||
selectionType: ['system', 'service'],
|
||||
selectionTypeSingle: 'system',
|
||||
|
||||
optionsStatus: [
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'running', label: 'Running' },
|
||||
{ key: 'dead', label: 'Dead' },
|
||||
],
|
||||
selectionStatus: [],
|
||||
selectionStatusSingle: 'dead',
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
export default {
|
||||
title: 'Components|Multi-Select Dropdown',
|
||||
};
|
||||
|
||||
let options1 = [
|
||||
{ key: 'option-1', label: 'Option One' },
|
||||
{ key: 'option-2', label: 'Option Two' },
|
||||
{ key: 'option-3', label: 'Option Three' },
|
||||
{ key: 'option-4', label: 'Option Four' },
|
||||
{ key: 'option-5', label: 'Option Five' },
|
||||
];
|
||||
|
||||
let selection1 = ['option-2', 'option-4', 'option-5'];
|
||||
|
||||
export let Standard = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown</h5>
|
||||
<MultiSelectDropdown
|
||||
@label="Example Dropdown"
|
||||
@options={{options1}}
|
||||
@selection={{selection1}}
|
||||
@onSelect={{action (mut selection1)}} />
|
||||
<p class="annotation">A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.</p>
|
||||
`,
|
||||
context: {
|
||||
options1,
|
||||
selection1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let RightAligned = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown right-aligned</h5>
|
||||
<div style="display:flex; justify-content:flex-end">
|
||||
<MultiSelectDropdown
|
||||
@label="Example right-aligned Dropdown"
|
||||
@options={{options1}}
|
||||
@selection={{selection1}}
|
||||
@onSelect={{action (mut selection1)}} />
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
options1,
|
||||
selection1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let ManyOptions = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown with many options</h5>
|
||||
<MultiSelectDropdown
|
||||
@label="Lots of options in here"
|
||||
@options={{optionsMany}}
|
||||
@selection={{selectionMany}}
|
||||
@onSelect={{action (mut selectionMany)}} />
|
||||
<p class="annotation">
|
||||
A strength of the multi-select-dropdown is its simple presentation. It is quick to select options and it is quick to remove options.
|
||||
However, this strength becomes a weakness when there are too many options. Since the selection isn't pinned in any way, removing a selection
|
||||
can become an adventure of scrolling up and down. Also since the selection isn't pinned, this component can't support search, since search would
|
||||
entirely mask the selection.
|
||||
</p>
|
||||
`,
|
||||
context: {
|
||||
optionsMany: Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
|
||||
selectionMany: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let Bar = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Multi-Select Dropdown bar</h5>
|
||||
<div class="button-bar">
|
||||
<MultiSelectDropdown
|
||||
@label="Datacenter"
|
||||
@options={{optionsDatacenter}}
|
||||
@selection={{selectionDatacenter}}
|
||||
@onSelect={{action (mut selectionDatacenter)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Type"
|
||||
@options={{optionsType}}
|
||||
@selection={{selectionType}}
|
||||
@onSelect={{action (mut selectionType)}} />
|
||||
<MultiSelectDropdown
|
||||
@label="Status"
|
||||
@options={{optionsStatus}}
|
||||
@selection={{selectionStatus}}
|
||||
@onSelect={{action (mut selectionStatus)}} />
|
||||
</div>
|
||||
<p class="annotation">
|
||||
Since this is a core component for faceted search, it makes sense to letruct an arrangement of multi-select dropdowns.
|
||||
Do this by wrapping all the options in a <code>.button-bar</code> container.
|
||||
</p>
|
||||
`,
|
||||
context: {
|
||||
optionsDatacenter: [
|
||||
{ key: 'pdx-1', label: 'pdx-1' },
|
||||
{ key: 'jfk-1', label: 'jfk-1' },
|
||||
{ key: 'jfk-2', label: 'jfk-2' },
|
||||
{ key: 'muc-1', label: 'muc-1' },
|
||||
],
|
||||
selectionDatacenter: ['jfk-1', 'jfk-2'],
|
||||
|
||||
optionsType: [
|
||||
{ key: 'batch', label: 'Batch' },
|
||||
{ key: 'service', label: 'Service' },
|
||||
{ key: 'system', label: 'System' },
|
||||
{ key: 'periodic', label: 'Periodic' },
|
||||
{ key: 'parameterized', label: 'Parameterized' },
|
||||
],
|
||||
selectionType: ['system', 'service'],
|
||||
|
||||
optionsStatus: [
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'running', label: 'Running' },
|
||||
{ key: 'dead', label: 'Dead' },
|
||||
],
|
||||
selectionStatus: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -2,13 +2,11 @@
|
||||
import { currentURL } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { selectChoose } from 'ember-power-select/test-support';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import moment from 'moment';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
|
||||
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
|
||||
import JobsList from 'nomad-ui/tests/pages/jobs/list';
|
||||
|
||||
moduleForJob('Acceptance | job detail (batch)', 'allocations', () =>
|
||||
server.create('job', { type: 'batch', shallow: true })
|
||||
@@ -128,27 +126,6 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
|
||||
assert.ok(JobDetail.statFor('namespace').text, 'Namespace included in stats');
|
||||
});
|
||||
|
||||
test('when switching namespaces, the app redirects to /jobs with the new namespace', async function(assert) {
|
||||
const namespace = server.db.namespaces.find(job.namespaceId);
|
||||
const otherNamespace = server.db.namespaces.toArray().find(ns => ns !== namespace).name;
|
||||
|
||||
await JobDetail.visit({ id: job.id, namespace: namespace.name });
|
||||
|
||||
// TODO: Migrate to Page Objects
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', otherNamespace);
|
||||
assert.equal(currentURL().split('?')[0], '/jobs', 'Navigated to /jobs');
|
||||
|
||||
const jobs = server.db.jobs
|
||||
.where({ namespace: otherNamespace })
|
||||
.sortBy('modifyIndex')
|
||||
.reverse();
|
||||
|
||||
assert.equal(JobsList.jobs.length, jobs.length, 'Shows the right number of jobs');
|
||||
JobsList.jobs.forEach((jobRow, index) => {
|
||||
assert.equal(jobRow.name, jobs[index].name, `Job ${index} is right`);
|
||||
});
|
||||
});
|
||||
|
||||
test('the exec button state can change between namespaces', async function(assert) {
|
||||
const job1 = server.create('job', {
|
||||
status: 'running',
|
||||
|
||||
@@ -59,6 +59,7 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
const jobRow = JobsList.jobs.objectAt(0);
|
||||
|
||||
assert.equal(jobRow.name, job.name, 'Name');
|
||||
assert.notOk(jobRow.hasNamespace);
|
||||
assert.equal(jobRow.link, `/ui/jobs/${job.id}`, 'Detail Link');
|
||||
assert.equal(jobRow.status, job.status, 'Status');
|
||||
assert.equal(jobRow.type, typeForJob(job), 'Type');
|
||||
@@ -93,41 +94,6 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
});
|
||||
|
||||
test('the job run button state can change between namespaces', async function(assert) {
|
||||
server.createList('namespace', 2);
|
||||
const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id });
|
||||
const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id });
|
||||
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
const policy = server.create('policy', {
|
||||
id: 'something',
|
||||
name: 'something',
|
||||
rulesJSON: {
|
||||
Namespaces: [
|
||||
{
|
||||
Name: job1.namespaceId,
|
||||
Capabilities: ['list-jobs', 'submit-job'],
|
||||
},
|
||||
{
|
||||
Name: job2.namespaceId,
|
||||
Capabilities: ['list-jobs'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
|
||||
await JobsList.visit();
|
||||
assert.notOk(JobsList.runJobButton.isDisabled);
|
||||
|
||||
const secondNamespace = server.db.namespaces[1];
|
||||
await JobsList.visit({ namespace: secondNamespace.id });
|
||||
assert.ok(JobsList.runJobButton.isDisabled);
|
||||
});
|
||||
|
||||
test('the anonymous policy is fetched to check whether to show the job run button', async function(assert) {
|
||||
window.localStorage.removeItem('nomadTokenSecret');
|
||||
|
||||
@@ -179,7 +145,18 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param');
|
||||
});
|
||||
|
||||
test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', async function(assert) {
|
||||
test('when a cluster has namespaces, each job row includes the job namespace', async function(assert) {
|
||||
server.createList('namespace', 2);
|
||||
server.createList('job', 2);
|
||||
const job = server.db.jobs.sortBy('modifyIndex').reverse()[0];
|
||||
|
||||
await JobsList.visit({ namespace: '*' });
|
||||
|
||||
const jobRow = JobsList.jobs.objectAt(0);
|
||||
assert.equal(jobRow.namespace, job.namespaceId);
|
||||
});
|
||||
|
||||
test('when the namespace query param is set, only matching jobs are shown', async function(assert) {
|
||||
server.createList('namespace', 2);
|
||||
const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id });
|
||||
const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id });
|
||||
@@ -213,12 +190,30 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
test('the jobs list page has appropriate faceted search options', async function(assert) {
|
||||
await JobsList.visit();
|
||||
|
||||
assert.ok(JobsList.facets.namespace.isHidden, 'Namespace facet not found (no namespaces)');
|
||||
assert.ok(JobsList.facets.type.isPresent, 'Type facet found');
|
||||
assert.ok(JobsList.facets.status.isPresent, 'Status facet found');
|
||||
assert.ok(JobsList.facets.datacenter.isPresent, 'Datacenter facet found');
|
||||
assert.ok(JobsList.facets.prefix.isPresent, 'Prefix facet found');
|
||||
});
|
||||
|
||||
testSingleSelectFacet('Namespace', {
|
||||
facet: JobsList.facets.namespace,
|
||||
paramName: 'namespace',
|
||||
expectedOptions: ['All (*)', 'default', 'namespace-2'],
|
||||
optionToSelect: 'namespace-2',
|
||||
async beforeEach() {
|
||||
server.create('namespace', { id: 'default' });
|
||||
server.create('namespace', { id: 'namespace-2' });
|
||||
server.createList('job', 2, { namespaceId: 'default' });
|
||||
server.createList('job', 2, { namespaceId: 'namespace-2' });
|
||||
await JobsList.visit();
|
||||
},
|
||||
filter(job, selection) {
|
||||
return job.namespaceId === selection;
|
||||
},
|
||||
});
|
||||
|
||||
testFacet('Type', {
|
||||
facet: JobsList.facets.type,
|
||||
paramName: 'type',
|
||||
@@ -352,7 +347,9 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
server.createList('namespace', 2);
|
||||
|
||||
const namespace = server.db.namespaces[1];
|
||||
await JobsList.visit({ namespace: namespace.id });
|
||||
await JobsList.visit();
|
||||
await JobsList.facets.namespace.toggle();
|
||||
await JobsList.facets.namespace.options.objectAt(2).select();
|
||||
|
||||
await Layout.gutter.visitStorage();
|
||||
|
||||
@@ -369,24 +366,73 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
},
|
||||
});
|
||||
|
||||
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
|
||||
async function facetOptions(assert, beforeEach, facet, expectedOptions) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
let expectation;
|
||||
if (typeof expectedOptions === 'function') {
|
||||
expectation = expectedOptions(server.db.jobs);
|
||||
} else {
|
||||
expectation = expectedOptions;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
facet.options.map(option => option.label.trim()),
|
||||
expectation,
|
||||
'Options for facet are as expected'
|
||||
);
|
||||
}
|
||||
|
||||
function testSingleSelectFacet(
|
||||
label,
|
||||
{ facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
|
||||
) {
|
||||
test(`the ${label} facet has the correct options`, async function(assert) {
|
||||
await facetOptions(assert, beforeEach, facet, expectedOptions);
|
||||
});
|
||||
|
||||
test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
let expectation;
|
||||
if (typeof expectedOptions === 'function') {
|
||||
expectation = expectedOptions(server.db.jobs);
|
||||
} else {
|
||||
expectation = expectedOptions;
|
||||
}
|
||||
const option = facet.options.findOneBy('label', optionToSelect);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
assert.deepEqual(
|
||||
facet.options.map(option => option.label.trim()),
|
||||
expectation,
|
||||
'Options for facet are as expected'
|
||||
const expectedJobs = server.db.jobs
|
||||
.filter(job => filter(job, selection))
|
||||
.sortBy('modifyIndex')
|
||||
.reverse();
|
||||
|
||||
JobsList.jobs.forEach((job, index) => {
|
||||
assert.equal(
|
||||
job.id,
|
||||
expectedJobs[index].id,
|
||||
`Job at ${index} is ${expectedJobs[index].id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
const option = facet.options.objectAt(1);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes(`${paramName}=${selection}`),
|
||||
'URL has the correct query param key and value'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
|
||||
test(`the ${label} facet has the correct options`, async function(assert) {
|
||||
await facetOptions(assert, beforeEach, facet, expectedOptions);
|
||||
});
|
||||
|
||||
test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) {
|
||||
let option;
|
||||
@@ -452,9 +498,8 @@ module('Acceptance | jobs list', function(hooks) {
|
||||
await option2.toggle();
|
||||
selection.push(option2.key);
|
||||
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`,
|
||||
assert.ok(
|
||||
currentURL().includes(encodeURIComponent(JSON.stringify(selection))),
|
||||
'URL has the correct query param key and value'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { currentURL } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import { selectChoose } from 'ember-power-select/test-support';
|
||||
import JobsList from 'nomad-ui/tests/pages/jobs/list';
|
||||
import ClientsList from 'nomad-ui/tests/pages/clients/list';
|
||||
import Allocation from 'nomad-ui/tests/pages/allocations/detail';
|
||||
import PluginsList from 'nomad-ui/tests/pages/storage/plugins/list';
|
||||
import VolumesList from 'nomad-ui/tests/pages/storage/volumes/list';
|
||||
|
||||
module('Acceptance | namespaces (disabled)', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
server.create('agent');
|
||||
server.create('node');
|
||||
server.createList('job', 5, { createAllocations: false });
|
||||
});
|
||||
|
||||
test('the namespace switcher is not in the gutter menu', async function(assert) {
|
||||
await JobsList.visit();
|
||||
assert.notOk(JobsList.namespaceSwitcher.isPresent, 'No namespace switcher found');
|
||||
});
|
||||
|
||||
test('the jobs request is made with no query params', async function(assert) {
|
||||
await JobsList.visit();
|
||||
|
||||
const request = server.pretender.handledRequests.findBy('url', '/v1/jobs');
|
||||
assert.equal(request.queryParams.namespace, undefined, 'No namespace query param');
|
||||
});
|
||||
});
|
||||
|
||||
module('Acceptance | namespaces (enabled)', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
server.createList('namespace', 3);
|
||||
server.create('agent');
|
||||
server.create('node');
|
||||
server.createList('job', 5);
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('it passes an accessibility audit', async function(assert) {
|
||||
await JobsList.visit();
|
||||
await a11yAudit(assert);
|
||||
});
|
||||
|
||||
test('the namespace switcher lists all namespaces', async function(assert) {
|
||||
const namespaces = server.db.namespaces;
|
||||
|
||||
await JobsList.visit();
|
||||
|
||||
assert.ok(JobsList.namespaceSwitcher.isPresent, 'Namespace switcher found');
|
||||
await JobsList.namespaceSwitcher.open();
|
||||
// TODO this selector should be scoped to only the namespace switcher options,
|
||||
// but ember-wormhole makes that difficult.
|
||||
assert.equal(
|
||||
JobsList.namespaceSwitcher.options.length,
|
||||
namespaces.length,
|
||||
'All namespaces are in the switcher'
|
||||
);
|
||||
assert.equal(
|
||||
JobsList.namespaceSwitcher.options.objectAt(0).label,
|
||||
'Namespace: default',
|
||||
'The first namespace is always the default one'
|
||||
);
|
||||
|
||||
const sortedNamespaces = namespaces.slice(1).sortBy('name');
|
||||
JobsList.namespaceSwitcher.options.forEach((option, index) => {
|
||||
// Default Namespace handled separately
|
||||
if (index === 0) return;
|
||||
|
||||
const namespace = sortedNamespaces[index - 1];
|
||||
assert.equal(
|
||||
option.label,
|
||||
`Namespace: ${namespace.name}`,
|
||||
`index ${index}: ${namespace.name}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('changing the namespace sets the namespace in localStorage', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
|
||||
await JobsList.visit();
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
assert.equal(
|
||||
window.localStorage.nomadActiveNamespace,
|
||||
namespace.id,
|
||||
'Active namespace was set'
|
||||
);
|
||||
});
|
||||
|
||||
test('changing the namespace refreshes the jobs list when on the jobs page', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
|
||||
await JobsList.visit();
|
||||
|
||||
let requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/jobs'));
|
||||
assert.equal(requests.length, 1, 'First request to jobs');
|
||||
assert.equal(
|
||||
requests[0].queryParams.namespace,
|
||||
undefined,
|
||||
'Namespace query param is defaulted to "default"/undefined'
|
||||
);
|
||||
|
||||
// TODO: handle this with Page Objects
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/jobs'));
|
||||
assert.equal(requests.length, 2, 'Second request to jobs');
|
||||
assert.equal(
|
||||
requests[1].queryParams.namespace,
|
||||
namespace.name,
|
||||
'Namespace query param on second request'
|
||||
);
|
||||
});
|
||||
|
||||
test('changing the namespace in the clients hierarchy navigates to the jobs page', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
|
||||
await ClientsList.visit();
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
assert.equal(currentURL(), `/jobs?namespace=${namespace.name}`);
|
||||
});
|
||||
|
||||
test('changing the namespace in the allocations hierarchy navigates to the jobs page', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
const allocation = server.create('allocation', { job: server.db.jobs[0] });
|
||||
|
||||
await Allocation.visit({ id: allocation.id });
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
assert.equal(currentURL(), `/jobs?namespace=${namespace.name}`);
|
||||
});
|
||||
|
||||
test('changing the namespace in the storage hierarchy navigates to the volumes page', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
|
||||
await PluginsList.visit();
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
assert.equal(currentURL(), `/csi/volumes?namespace=${namespace.name}`);
|
||||
});
|
||||
|
||||
test('changing the namespace refreshes the volumes list when on the volumes page', async function(assert) {
|
||||
const namespace = server.db.namespaces[1];
|
||||
|
||||
await VolumesList.visit();
|
||||
|
||||
let requests = server.pretender.handledRequests.filter(req =>
|
||||
req.url.startsWith('/v1/volumes')
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].queryParams.namespace, undefined);
|
||||
|
||||
// TODO: handle this with Page Objects
|
||||
await selectChoose('[data-test-namespace-switcher-parent]', namespace.name);
|
||||
|
||||
requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/volumes'));
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].queryParams.namespace, namespace.name);
|
||||
});
|
||||
});
|
||||
@@ -359,7 +359,7 @@ module('Acceptance | optimize', function(hooks) {
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
await Optimize.visit();
|
||||
|
||||
assert.equal(currentURL(), '/jobs');
|
||||
assert.equal(currentURL(), '/jobs?namespace=default');
|
||||
assert.ok(Layout.gutter.optimize.isHidden);
|
||||
});
|
||||
|
||||
@@ -444,39 +444,6 @@ module('Acceptance | optimize search and facets', function(hooks) {
|
||||
assert.ok(Optimize.recommendationSummaries[0].isActive);
|
||||
});
|
||||
|
||||
test('turning off the namespaces toggle narrows summaries to only the current namespace and changes an active summary if it has become filtered out', async function(assert) {
|
||||
server.create('job', {
|
||||
name: 'pppppp',
|
||||
createRecommendations: true,
|
||||
groupsCount: 1,
|
||||
groupTaskCount: 4,
|
||||
namespaceId: server.db.namespaces[1].id,
|
||||
});
|
||||
|
||||
// Ensure this job’s recommendations are sorted to the top of the table
|
||||
const futureSubmitTime = (Date.now() + 10000) * 1000000;
|
||||
server.db.recommendations.update({ submitTime: futureSubmitTime });
|
||||
|
||||
server.create('job', {
|
||||
name: 'oooooo',
|
||||
createRecommendations: true,
|
||||
groupsCount: 1,
|
||||
groupTaskCount: 6,
|
||||
namespaceId: server.db.namespaces[0].id,
|
||||
});
|
||||
|
||||
await Optimize.visit();
|
||||
|
||||
assert.ok(Optimize.allNamespacesToggle.isActive);
|
||||
|
||||
await Optimize.allNamespacesToggle.toggle();
|
||||
|
||||
assert.equal(Optimize.recommendationSummaries.length, 1);
|
||||
assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('ooo'));
|
||||
assert.ok(currentURL().includes('all-namespaces=false'));
|
||||
assert.equal(Optimize.card.slug.jobName, 'oooooo');
|
||||
});
|
||||
|
||||
test('the namespaces toggle doesn’t show when there aren’t namespaces', async function(assert) {
|
||||
server.db.namespaces.remove();
|
||||
|
||||
@@ -488,7 +455,7 @@ module('Acceptance | optimize search and facets', function(hooks) {
|
||||
|
||||
await Optimize.visit();
|
||||
|
||||
assert.ok(Optimize.allNamespacesToggle.isHidden);
|
||||
assert.ok(Optimize.facets.namespace.isHidden);
|
||||
});
|
||||
|
||||
test('processing a summary moves to the next one in the sorted list', async function(assert) {
|
||||
@@ -547,12 +514,28 @@ module('Acceptance | optimize search and facets', function(hooks) {
|
||||
|
||||
await Optimize.visit();
|
||||
|
||||
assert.ok(Optimize.facets.namespace.isPresent, 'Namespace facet found');
|
||||
assert.ok(Optimize.facets.type.isPresent, 'Type facet found');
|
||||
assert.ok(Optimize.facets.status.isPresent, 'Status facet found');
|
||||
assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found');
|
||||
assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found');
|
||||
});
|
||||
|
||||
testSingleSelectFacet('Namespace', {
|
||||
facet: Optimize.facets.namespace,
|
||||
paramName: 'namespace',
|
||||
expectedOptions: ['All (*)', 'default', 'namespace-1'],
|
||||
optionToSelect: 'namespace-1',
|
||||
async beforeEach() {
|
||||
server.createList('job', 2, { namespaceId: 'default', createRecommendations: true });
|
||||
server.createList('job', 2, { namespaceId: 'namespace-1', createRecommendations: true });
|
||||
await Optimize.visit();
|
||||
},
|
||||
filter(taskGroup, selection) {
|
||||
return taskGroup.job.namespaceId === selection;
|
||||
},
|
||||
});
|
||||
|
||||
testFacet('Type', {
|
||||
facet: Optimize.facets.type,
|
||||
paramName: 'type',
|
||||
@@ -683,24 +666,73 @@ module('Acceptance | optimize search and facets', function(hooks) {
|
||||
selection.find(prefix => taskGroup.job.name.startsWith(prefix)),
|
||||
});
|
||||
|
||||
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
|
||||
async function facetOptions(assert, beforeEach, facet, expectedOptions) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
let expectation;
|
||||
if (typeof expectedOptions === 'function') {
|
||||
expectation = expectedOptions(server.db.jobs);
|
||||
} else {
|
||||
expectation = expectedOptions;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
facet.options.map(option => option.label.trim()),
|
||||
expectation,
|
||||
'Options for facet are as expected'
|
||||
);
|
||||
}
|
||||
|
||||
function testSingleSelectFacet(
|
||||
label,
|
||||
{ facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
|
||||
) {
|
||||
test(`the ${label} facet has the correct options`, async function(assert) {
|
||||
await facetOptions.call(this, assert, beforeEach, facet, expectedOptions);
|
||||
});
|
||||
|
||||
test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
let expectation;
|
||||
if (typeof expectedOptions === 'function') {
|
||||
expectation = expectedOptions(server.db.jobs);
|
||||
} else {
|
||||
expectation = expectedOptions;
|
||||
}
|
||||
const option = facet.options.findOneBy('label', optionToSelect);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
assert.deepEqual(
|
||||
facet.options.map(option => option.label.trim()),
|
||||
expectation,
|
||||
'Options for facet are as expected'
|
||||
const sortedRecommendations = server.db.recommendations.sortBy('submitTime').reverse();
|
||||
|
||||
const recommendationTaskGroups = server.schema.tasks
|
||||
.find(sortedRecommendations.mapBy('taskId').uniq())
|
||||
.models.mapBy('taskGroup')
|
||||
.uniqBy('id')
|
||||
.filter(group => filter(group, selection));
|
||||
|
||||
Optimize.recommendationSummaries.forEach((summary, index) => {
|
||||
const group = recommendationTaskGroups[index];
|
||||
assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
const option = facet.options.objectAt(1);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes(`${paramName}=${selection}`),
|
||||
'URL has the correct query param key and value'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
|
||||
test(`the ${label} facet has the correct options`, async function(assert) {
|
||||
await facetOptions.call(this, assert, beforeEach, facet, expectedOptions);
|
||||
});
|
||||
|
||||
test(`the ${label} facet filters the recommendation summaries by ${label}`, async function(assert) {
|
||||
let option;
|
||||
|
||||
@@ -298,11 +298,7 @@ module('Acceptance | task detail (different namespace)', function(hooks) {
|
||||
const job = server.db.jobs.find(jobId);
|
||||
|
||||
await Layout.breadcrumbFor('jobs.index').visit();
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
'/jobs?namespace=other-namespace',
|
||||
'Jobs breadcrumb links correctly'
|
||||
);
|
||||
assert.equal(currentURL(), '/jobs?namespace=default', 'Jobs breadcrumb links correctly');
|
||||
|
||||
await Task.visit({ id: allocation.id, name: task.name });
|
||||
await Layout.breadcrumbFor('jobs.job.index').visit();
|
||||
|
||||
@@ -148,23 +148,6 @@ module('Acceptance | tokens', function(hooks) {
|
||||
assert.notOk(find('[data-test-job-row]'), 'No jobs found');
|
||||
});
|
||||
|
||||
test('when namespaces are enabled, setting or clearing a token refetches namespaces available with new permissions', async function(assert) {
|
||||
const { secretId } = clientToken;
|
||||
|
||||
server.createList('namespace', 2);
|
||||
await Tokens.visit();
|
||||
|
||||
const requests = server.pretender.handledRequests;
|
||||
|
||||
assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 1);
|
||||
|
||||
await Tokens.secret(secretId).submit();
|
||||
assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 2);
|
||||
|
||||
await Tokens.clear();
|
||||
assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 3);
|
||||
});
|
||||
|
||||
test('when the ott query parameter is present upon application load it’s exchanged for a token', async function(assert) {
|
||||
const { oneTimeSecret, secretId } = managementToken;
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ module('Acceptance | volume detail (with namespaces)', function(hooks) {
|
||||
});
|
||||
|
||||
test('/csi/volumes/:id detail ribbon includes the namespace of the volume', async function(assert) {
|
||||
await VolumeDetail.visit({ id: volume.id });
|
||||
await VolumeDetail.visit({ id: volume.id, namespace: volume.namespaceId });
|
||||
|
||||
assert.ok(VolumeDetail.hasNamespace);
|
||||
assert.ok(VolumeDetail.namespace.includes(volume.namespaceId || 'default'));
|
||||
|
||||
@@ -82,6 +82,7 @@ module('Acceptance | volumes list', function(hooks) {
|
||||
const nodeHealthStr = volume.nodesHealthy > 0 ? 'Healthy' : 'Unhealthy';
|
||||
|
||||
assert.equal(volumeRow.name, volume.id);
|
||||
assert.notOk(volumeRow.hasNamespace);
|
||||
assert.equal(volumeRow.schedulable, volume.schedulable ? 'Schedulable' : 'Unschedulable');
|
||||
assert.equal(volumeRow.controllerHealth, controllerHealthStr);
|
||||
assert.equal(
|
||||
@@ -93,18 +94,19 @@ module('Acceptance | volumes list', function(hooks) {
|
||||
});
|
||||
|
||||
test('each volume row should link to the corresponding volume', async function(assert) {
|
||||
const volume = server.create('csi-volume');
|
||||
const [, secondNamespace] = server.createList('namespace', 2);
|
||||
const volume = server.create('csi-volume', { namespaceId: secondNamespace.id });
|
||||
|
||||
await VolumesList.visit();
|
||||
await VolumesList.visit({ namespace: '*' });
|
||||
|
||||
await VolumesList.volumes.objectAt(0).clickName();
|
||||
assert.equal(currentURL(), `/csi/volumes/${volume.id}`);
|
||||
assert.equal(currentURL(), `/csi/volumes/${volume.id}?namespace=${secondNamespace.id}`);
|
||||
|
||||
await VolumesList.visit();
|
||||
assert.equal(currentURL(), '/csi/volumes');
|
||||
await VolumesList.visit({ namespace: '*' });
|
||||
assert.equal(currentURL(), '/csi/volumes?namespace=*');
|
||||
|
||||
await VolumesList.volumes.objectAt(0).clickRow();
|
||||
assert.equal(currentURL(), `/csi/volumes/${volume.id}`);
|
||||
assert.equal(currentURL(), `/csi/volumes/${volume.id}?namespace=${secondNamespace.id}`);
|
||||
});
|
||||
|
||||
test('when there are no volumes, there is an empty message', async function(assert) {
|
||||
@@ -138,6 +140,16 @@ module('Acceptance | volumes list', function(hooks) {
|
||||
assert.equal(currentURL(), '/csi/volumes?search=foobar');
|
||||
});
|
||||
|
||||
test('when the cluster has namespaces, each volume row includes the volume namespace', async function(assert) {
|
||||
server.createList('namespace', 2);
|
||||
const volume = server.create('csi-volume');
|
||||
|
||||
await VolumesList.visit({ namespace: '*' });
|
||||
|
||||
const volumeRow = VolumesList.volumes.objectAt(0);
|
||||
assert.equal(volumeRow.namespace, volume.namespaceId);
|
||||
});
|
||||
|
||||
test('when the namespace query param is set, only matching volumes are shown and the namespace value is forwarded to app state', async function(assert) {
|
||||
server.createList('namespace', 2);
|
||||
const volume1 = server.create('csi-volume', { namespaceId: server.db.namespaces[0].id });
|
||||
@@ -159,7 +171,9 @@ module('Acceptance | volumes list', function(hooks) {
|
||||
server.createList('namespace', 2);
|
||||
|
||||
const namespace = server.db.namespaces[1];
|
||||
await VolumesList.visit({ namespace: namespace.id });
|
||||
await VolumesList.visit();
|
||||
await VolumesList.facets.namespace.toggle();
|
||||
await VolumesList.facets.namespace.options.objectAt(2).select();
|
||||
|
||||
await Layout.gutter.visitJobs();
|
||||
|
||||
@@ -185,4 +199,79 @@ module('Acceptance | volumes list', function(hooks) {
|
||||
await VolumesList.visit();
|
||||
},
|
||||
});
|
||||
|
||||
testSingleSelectFacet('Namespace', {
|
||||
facet: VolumesList.facets.namespace,
|
||||
paramName: 'namespace',
|
||||
expectedOptions: ['All (*)', 'default', 'namespace-2'],
|
||||
optionToSelect: 'namespace-2',
|
||||
async beforeEach() {
|
||||
server.create('namespace', { id: 'default' });
|
||||
server.create('namespace', { id: 'namespace-2' });
|
||||
server.createList('csi-volume', 2, { namespaceId: 'default' });
|
||||
server.createList('csi-volume', 2, { namespaceId: 'namespace-2' });
|
||||
await VolumesList.visit();
|
||||
},
|
||||
filter(volume, selection) {
|
||||
return volume.namespaceId === selection;
|
||||
},
|
||||
});
|
||||
|
||||
function testSingleSelectFacet(
|
||||
label,
|
||||
{ facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
|
||||
) {
|
||||
test(`the ${label} facet has the correct options`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
let expectation;
|
||||
if (typeof expectedOptions === 'function') {
|
||||
expectation = expectedOptions(server.db.jobs);
|
||||
} else {
|
||||
expectation = expectedOptions;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
facet.options.map(option => option.label.trim()),
|
||||
expectation,
|
||||
'Options for facet are as expected'
|
||||
);
|
||||
});
|
||||
|
||||
test(`the ${label} facet filters the volumes list by ${label}`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
const option = facet.options.findOneBy('label', optionToSelect);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
const expectedVolumes = server.db.csiVolumes
|
||||
.filter(volume => filter(volume, selection))
|
||||
.sortBy('id');
|
||||
|
||||
VolumesList.volumes.forEach((volume, index) => {
|
||||
assert.equal(
|
||||
volume.name,
|
||||
expectedVolumes[index].name,
|
||||
`Volume at ${index} is ${expectedVolumes[index].name}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function(assert) {
|
||||
await beforeEach();
|
||||
await facet.toggle();
|
||||
|
||||
const option = facet.options.objectAt(1);
|
||||
const selection = option.key;
|
||||
await option.select();
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes(`${paramName}=${selection}`),
|
||||
'URL has the correct query param key and value'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export default ability => hooks => {
|
||||
hooks.beforeEach(function() {
|
||||
this.ability = this.owner.lookup(`ability:${ability}`);
|
||||
this.can = this.owner.lookup('service:can');
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
delete this.ability;
|
||||
delete this.can;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,10 +30,10 @@ module('Integration | Component | multi-select dropdown', function(hooks) {
|
||||
|
||||
const commonTemplate = hbs`
|
||||
<MultiSelectDropdown
|
||||
@label={{label}}
|
||||
@options={{options}}
|
||||
@selection={{selection}}
|
||||
@onSelect={{onSelect}} />
|
||||
@label={{this.label}}
|
||||
@options={{this.options}}
|
||||
@selection={{this.selection}}
|
||||
@onSelect={{this.onSelect}} />
|
||||
`;
|
||||
|
||||
test('component is initially closed', async function(assert) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { findAll, find, render } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import sinon from 'sinon';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
|
||||
module('Integration | Component | single-select dropdown', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
const commonProperties = () => ({
|
||||
label: 'Type',
|
||||
selection: 'nomad',
|
||||
options: [
|
||||
{ key: 'consul', label: 'Consul' },
|
||||
{ key: 'nomad', label: 'Nomad' },
|
||||
{ key: 'terraform', label: 'Terraform' },
|
||||
{ key: 'packer', label: 'Packer' },
|
||||
{ key: 'vagrant', label: 'Vagrant' },
|
||||
{ key: 'vault', label: 'Vault' },
|
||||
],
|
||||
onSelect: sinon.spy(),
|
||||
});
|
||||
|
||||
const commonTemplate = hbs`
|
||||
<SingleSelectDropdown
|
||||
@label={{this.label}}
|
||||
@options={{this.options}}
|
||||
@selection={{this.selection}}
|
||||
@onSelect={{this.onSelect}} />
|
||||
`;
|
||||
|
||||
test('component shows label and selection in the trigger', async function(assert) {
|
||||
const props = commonProperties();
|
||||
this.setProperties(props);
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(find('.ember-power-select-trigger').textContent.includes(props.label));
|
||||
assert.ok(
|
||||
find('.ember-power-select-trigger').textContent.includes(
|
||||
props.options.findBy('key', props.selection).label
|
||||
)
|
||||
);
|
||||
assert.notOk(find('[data-test-dropdown-options]'));
|
||||
|
||||
await componentA11yAudit(this.element, assert);
|
||||
});
|
||||
|
||||
test('all options are shown in the dropdown', async function(assert) {
|
||||
const props = commonProperties();
|
||||
this.setProperties(props);
|
||||
await render(commonTemplate);
|
||||
|
||||
await clickTrigger('[data-test-single-select-dropdown]');
|
||||
|
||||
assert.equal(
|
||||
findAll('.ember-power-select-option').length,
|
||||
props.options.length,
|
||||
'All options are shown'
|
||||
);
|
||||
findAll('.ember-power-select-option').forEach((optionEl, index) => {
|
||||
assert.equal(
|
||||
optionEl.querySelector('.dropdown-label').textContent.trim(),
|
||||
props.options[index].label
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting an option calls `onSelect` with the key for the selected option', async function(assert) {
|
||||
const props = commonProperties();
|
||||
this.setProperties(props);
|
||||
await render(commonTemplate);
|
||||
|
||||
const option = props.options.findBy('key', 'terraform');
|
||||
await selectChoose('[data-test-single-select-dropdown]', option.label);
|
||||
|
||||
assert.ok(props.onSelect.calledWith(option.key));
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
visitable,
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
import facet from 'nomad-ui/tests/pages/components/facet';
|
||||
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
|
||||
|
||||
export default create({
|
||||
@@ -72,9 +72,9 @@ export default create({
|
||||
},
|
||||
|
||||
facets: {
|
||||
class: facet('[data-test-class-facet]'),
|
||||
state: facet('[data-test-state-facet]'),
|
||||
datacenter: facet('[data-test-datacenter-facet]'),
|
||||
volume: facet('[data-test-volume-facet]'),
|
||||
class: multiFacet('[data-test-class-facet]'),
|
||||
state: multiFacet('[data-test-state-facet]'),
|
||||
datacenter: multiFacet('[data-test-datacenter-facet]'),
|
||||
volume: multiFacet('[data-test-volume-facet]'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import { isPresent, clickable, collection, text, attribute } from 'ember-cli-page-object';
|
||||
import { clickable, collection, text, attribute } from 'ember-cli-page-object';
|
||||
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
|
||||
export default scope => ({
|
||||
export const multiFacet = scope => ({
|
||||
scope,
|
||||
|
||||
isPresent: isPresent(),
|
||||
|
||||
toggle: clickable('[data-test-dropdown-trigger]'),
|
||||
|
||||
options: collection('[data-test-dropdown-option]', {
|
||||
testContainer: '#ember-testing',
|
||||
testContainer: '#ember-testing .ember-basic-dropdown-content',
|
||||
resetScope: true,
|
||||
label: text(),
|
||||
key: attribute('data-test-dropdown-option'),
|
||||
toggle: clickable('label'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const singleFacet = scope => ({
|
||||
scope,
|
||||
|
||||
async toggle() {
|
||||
await clickTrigger(this.scope);
|
||||
},
|
||||
|
||||
options: collection('.ember-power-select-option', {
|
||||
testContainer: '#ember-testing .ember-basic-dropdown-content',
|
||||
resetScope: true,
|
||||
label: text('[data-test-dropdown-option]'),
|
||||
key: attribute('data-test-dropdown-option', '[data-test-dropdown-option]'),
|
||||
async select() {
|
||||
// __parentTreeNode is clearly meant to be private in the page object API,
|
||||
// but it seems better to use that and keep the API symmetry across singleFacet
|
||||
// and multiFacet compared to moving select to the parent.
|
||||
const parentScope = this.__parentTreeNode.scope;
|
||||
await selectChoose(parentScope, this.label);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
visitable,
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
import facet from 'nomad-ui/tests/pages/components/facet';
|
||||
import { multiFacet, singleFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
|
||||
|
||||
export default create({
|
||||
@@ -31,12 +31,14 @@ export default create({
|
||||
jobs: collection('[data-test-job-row]', {
|
||||
id: attribute('data-test-job-row'),
|
||||
name: text('[data-test-job-name]'),
|
||||
namespace: text('[data-test-job-namespace]'),
|
||||
link: attribute('href', '[data-test-job-name] a'),
|
||||
status: text('[data-test-job-status]'),
|
||||
type: text('[data-test-job-type]'),
|
||||
priority: text('[data-test-job-priority]'),
|
||||
taskGroups: text('[data-test-job-task-groups]'),
|
||||
|
||||
hasNamespace: isPresent('[data-test-job-namespace]'),
|
||||
clickRow: clickable(),
|
||||
clickName: clickable('[data-test-job-name] a'),
|
||||
}),
|
||||
@@ -58,22 +60,13 @@ export default create({
|
||||
gotoClients: clickable('[data-test-error-clients-link]'),
|
||||
},
|
||||
|
||||
namespaceSwitcher: {
|
||||
isPresent: isPresent('[data-test-namespace-switcher-parent]'),
|
||||
open: clickable('[data-test-namespace-switcher-parent] .ember-power-select-trigger'),
|
||||
options: collection('.ember-power-select-option', {
|
||||
testContainer: '#ember-testing',
|
||||
resetScope: true,
|
||||
label: text(),
|
||||
}),
|
||||
},
|
||||
|
||||
pageSizeSelect: pageSizeSelect(),
|
||||
|
||||
facets: {
|
||||
type: facet('[data-test-type-facet]'),
|
||||
status: facet('[data-test-status-facet]'),
|
||||
datacenter: facet('[data-test-datacenter-facet]'),
|
||||
prefix: facet('[data-test-prefix-facet]'),
|
||||
namespace: singleFacet('[data-test-namespace-facet]'),
|
||||
type: multiFacet('[data-test-type-facet]'),
|
||||
status: multiFacet('[data-test-status-facet]'),
|
||||
datacenter: multiFacet('[data-test-datacenter-facet]'),
|
||||
prefix: multiFacet('[data-test-prefix-facet]'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,14 +50,6 @@ export default create({
|
||||
|
||||
gutter: {
|
||||
scope: '[data-test-gutter-menu]',
|
||||
namespaceSwitcher: {
|
||||
scope: '[data-test-namespace-switcher-parent]',
|
||||
isPresent: isPresent(),
|
||||
open: clickable('.ember-power-select-trigger'),
|
||||
options: collection('.ember-power-select-option', {
|
||||
label: text(),
|
||||
}),
|
||||
},
|
||||
visitJobs: clickable('[data-test-gutter-link="jobs"]'),
|
||||
|
||||
optimize: {
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
import recommendationCard from 'nomad-ui/tests/pages/components/recommendation-card';
|
||||
import facet from 'nomad-ui/tests/pages/components/facet';
|
||||
import toggle from 'nomad-ui/tests/pages/components/toggle';
|
||||
import { multiFacet, singleFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
|
||||
export default create({
|
||||
visit: visitable('/optimize'),
|
||||
@@ -22,14 +21,13 @@ export default create({
|
||||
},
|
||||
|
||||
facets: {
|
||||
type: facet('[data-test-type-facet]'),
|
||||
status: facet('[data-test-status-facet]'),
|
||||
datacenter: facet('[data-test-datacenter-facet]'),
|
||||
prefix: facet('[data-test-prefix-facet]'),
|
||||
namespace: singleFacet('[data-test-namespace-facet]'),
|
||||
type: multiFacet('[data-test-type-facet]'),
|
||||
status: multiFacet('[data-test-status-facet]'),
|
||||
datacenter: multiFacet('[data-test-datacenter-facet]'),
|
||||
prefix: multiFacet('[data-test-prefix-facet]'),
|
||||
},
|
||||
|
||||
allNamespacesToggle: toggle('[data-test-all-namespaces-toggle]'),
|
||||
|
||||
card: recommendationCard,
|
||||
|
||||
recommendationSummaries: collection('[data-test-recommendation-summary-row]', {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clickable, create, isPresent, text, visitable } from 'ember-cli-page-object';
|
||||
|
||||
import allocations from 'nomad-ui/tests/pages/components/allocations';
|
||||
import facet from 'nomad-ui/tests/pages/components/facet';
|
||||
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
|
||||
|
||||
export default create({
|
||||
@@ -22,7 +22,7 @@ export default create({
|
||||
pageSizeSelect: pageSizeSelect(),
|
||||
|
||||
facets: {
|
||||
health: facet('[data-test-health-facet]'),
|
||||
type: facet('[data-test-type-facet]'),
|
||||
health: multiFacet('[data-test-health-facet]'),
|
||||
type: multiFacet('[data-test-type-facet]'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
import error from 'nomad-ui/tests/pages/components/error';
|
||||
import { singleFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
|
||||
|
||||
export default create({
|
||||
@@ -20,12 +21,14 @@ export default create({
|
||||
|
||||
volumes: collection('[data-test-volume-row]', {
|
||||
name: text('[data-test-volume-name]'),
|
||||
namespace: text('[data-test-volume-namespace]'),
|
||||
schedulable: text('[data-test-volume-schedulable]'),
|
||||
controllerHealth: text('[data-test-volume-controller-health]'),
|
||||
nodeHealth: text('[data-test-volume-node-health]'),
|
||||
provider: text('[data-test-volume-provider]'),
|
||||
allocations: text('[data-test-volume-allocations]'),
|
||||
|
||||
hasNamespace: isPresent('[data-test-volume-namespace]'),
|
||||
clickRow: clickable(),
|
||||
clickName: clickable('[data-test-volume-name] a'),
|
||||
}),
|
||||
@@ -41,13 +44,7 @@ export default create({
|
||||
error: error(),
|
||||
pageSizeSelect: pageSizeSelect(),
|
||||
|
||||
namespaceSwitcher: {
|
||||
isPresent: isPresent('[data-test-namespace-switcher-parent]'),
|
||||
open: clickable('[data-test-namespace-switcher-parent] .ember-power-select-trigger'),
|
||||
options: collection('.ember-power-select-option', {
|
||||
testContainer: '#ember-testing',
|
||||
resetScope: true,
|
||||
label: text(),
|
||||
}),
|
||||
facets: {
|
||||
namespace: singleFacet('[data-test-namespace-facet]'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canExec);
|
||||
assert.ok(this.can.can('exec allocation'));
|
||||
});
|
||||
|
||||
test('it permits alloc exec for management tokens', function(assert) {
|
||||
@@ -26,15 +26,12 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canExec);
|
||||
assert.ok(this.can.can('exec allocation'));
|
||||
});
|
||||
|
||||
test('it permits alloc exec for client tokens with a policy that has namespace alloc-exec', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -57,15 +54,12 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canExec);
|
||||
assert.ok(this.can.can('exec allocation', null, { namespace: 'aNamespace' }));
|
||||
});
|
||||
|
||||
test('it permits alloc exec for client tokens with a policy that has default namespace alloc-exec and no capabilities for active namespace', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'anotherNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -92,15 +86,12 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canExec);
|
||||
assert.ok(this.can.can('exec allocation', null, { namespace: 'anotherNamespace' }));
|
||||
});
|
||||
|
||||
test('it blocks alloc exec for client tokens with a policy that has no alloc-exec capability', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -123,15 +114,12 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.notOk(this.ability.canExec);
|
||||
assert.ok(this.can.cannot('exec allocation', null, { namespace: 'aNamespace' }));
|
||||
});
|
||||
|
||||
test('it handles globs in namespace names', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -174,27 +162,17 @@ module('Unit | Ability | allocation', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const systemService = this.owner.lookup('service:system');
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-web');
|
||||
assert.notOk(this.ability.canExec);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-api');
|
||||
assert.ok(this.ability.canExec);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-other');
|
||||
assert.ok(this.ability.canExec);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-suffixed');
|
||||
assert.ok(this.ability.canExec);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-more-suffixed');
|
||||
assert.notOk(
|
||||
this.ability.canExec,
|
||||
assert.ok(this.can.cannot('exec allocation', null, { namespace: 'production-web' }));
|
||||
assert.ok(this.can.can('exec allocation', null, { namespace: 'production-api' }));
|
||||
assert.ok(this.can.can('exec allocation', null, { namespace: 'production-other' }));
|
||||
assert.ok(this.can.can('exec allocation', null, { namespace: 'something-suffixed' }));
|
||||
assert.ok(
|
||||
this.can.cannot('exec allocation', null, { namespace: 'something-more-suffixed' }),
|
||||
'expected the namespace with the greatest number of matched characters to be chosen'
|
||||
);
|
||||
|
||||
systemService.set('activeNamespace.name', '000-abc-999');
|
||||
assert.ok(this.ability.canExec, 'expected to be able to match against more than one wildcard');
|
||||
assert.ok(
|
||||
this.can.can('exec allocation', null, { namespace: '000-abc-999' }),
|
||||
'expected to be able to match against more than one wildcard'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,9 +32,6 @@ module('Unit | Ability | job', function(hooks) {
|
||||
test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -57,15 +54,12 @@ module('Unit | Ability | job', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRun);
|
||||
assert.ok(this.can.can('run job', null, { namespace: 'aNamespace' }));
|
||||
});
|
||||
|
||||
test('it permits job run for client tokens with a policy that has default namespace submit-job and no capabilities for active namespace', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'anotherNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -92,15 +86,12 @@ module('Unit | Ability | job', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRun);
|
||||
assert.ok(this.can.can('run job', null, { namespace: 'anotherNamespace' }));
|
||||
});
|
||||
|
||||
test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -123,7 +114,7 @@ module('Unit | Ability | job', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.notOk(this.ability.canRun);
|
||||
assert.ok(this.can.cannot('run job', null, { namespace: 'aNamespace' }));
|
||||
});
|
||||
|
||||
test('job scale requires a client token with the submit-job or scale-job capability', function(assert) {
|
||||
@@ -142,9 +133,6 @@ module('Unit | Ability | job', function(hooks) {
|
||||
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -157,24 +145,21 @@ module('Unit | Ability | job', function(hooks) {
|
||||
this.owner.register('service:token', mockToken);
|
||||
const tokenService = this.owner.lookup('service:token');
|
||||
|
||||
assert.notOk(this.ability.canScale);
|
||||
assert.ok(this.can.cannot('scale job', null, { namespace: 'aNamespace' }));
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'scale-job'));
|
||||
assert.ok(this.ability.canScale);
|
||||
assert.ok(this.can.can('scale job', null, { namespace: 'aNamespace' }));
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'submit-job'));
|
||||
assert.ok(this.ability.canScale);
|
||||
assert.ok(this.can.can('scale job', null, { namespace: 'aNamespace' }));
|
||||
|
||||
tokenService.set('selfTokenPolicies', makePolicies('bNamespace', 'scale-job'));
|
||||
assert.notOk(this.ability.canScale);
|
||||
assert.ok(this.can.cannot('scale job', null, { namespace: 'aNamespace' }));
|
||||
});
|
||||
|
||||
test('it handles globs in namespace names', function(assert) {
|
||||
const mockSystem = Service.extend({
|
||||
aclEnabled: true,
|
||||
activeNamespace: {
|
||||
name: 'aNamespace',
|
||||
},
|
||||
});
|
||||
|
||||
const mockToken = Service.extend({
|
||||
@@ -217,27 +202,17 @@ module('Unit | Ability | job', function(hooks) {
|
||||
this.owner.register('service:system', mockSystem);
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
const systemService = this.owner.lookup('service:system');
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-web');
|
||||
assert.notOk(this.ability.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-api');
|
||||
assert.ok(this.ability.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'production-other');
|
||||
assert.ok(this.ability.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-suffixed');
|
||||
assert.ok(this.ability.canRun);
|
||||
|
||||
systemService.set('activeNamespace.name', 'something-more-suffixed');
|
||||
assert.notOk(
|
||||
this.ability.canRun,
|
||||
assert.ok(this.can.cannot('run job', null, { namespace: 'production-web' }));
|
||||
assert.ok(this.can.can('run job', null, { namespace: 'production-api' }));
|
||||
assert.ok(this.can.can('run job', null, { namespace: 'production-other' }));
|
||||
assert.ok(this.can.can('run job', null, { namespace: 'something-suffixed' }));
|
||||
assert.ok(
|
||||
this.can.cannot('run job', null, { namespace: 'something-more-suffixed' }),
|
||||
'expected the namespace with the greatest number of matched characters to be chosen'
|
||||
);
|
||||
|
||||
systemService.set('activeNamespace.name', '000-abc-999');
|
||||
assert.ok(this.ability.canRun, 'expected to be able to match against more than one wildcard');
|
||||
assert.ok(
|
||||
this.can.can('run job', null, { namespace: '000-abc-999' }),
|
||||
'expected to be able to match against more than one wildcard'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,20 +58,6 @@ module('Unit | Adapter | Volume', function(hooks) {
|
||||
assert.deepEqual(pretender.handledRequests.mapBy('url'), ['/v1/volumes?type=csi']);
|
||||
});
|
||||
|
||||
test('When a namespace is set in localStorage and the volume endpoint is queried, the namespace is in the query string', async function(assert) {
|
||||
const { pretender } = this.server;
|
||||
|
||||
window.localStorage.nomadActiveNamespace = 'some-namespace';
|
||||
await this.initializeUI();
|
||||
|
||||
this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {});
|
||||
await settled();
|
||||
|
||||
assert.deepEqual(pretender.handledRequests.mapBy('url'), [
|
||||
'/v1/volumes?namespace=some-namespace&type=csi',
|
||||
]);
|
||||
});
|
||||
|
||||
test('When the volume has a namespace other than default, it is in the URL', async function(assert) {
|
||||
const { pretender } = this.server;
|
||||
const volumeName = 'csi/volume-1';
|
||||
|
||||
Reference in New Issue
Block a user