diff --git a/ui/app/abilities/variable.js b/ui/app/abilities/variable.js index 3a9f39cc4..507391e93 100644 --- a/ui/app/abilities/variable.js +++ b/ui/app/abilities/variable.js @@ -10,10 +10,22 @@ export default class extends AbstractAbility { ) canList; + @or( + 'bypassAuthorization', + 'selfTokenIsManagement', + 'policiesSupportVariableCreation' + ) + canCreate; + @computed('rulesForNamespace.@each.capabilities') get policiesSupportVariableView() { return this.rulesForNamespace.some((rules) => { return get(rules, 'SecureVariables'); }); } + + @computed('rulesForNamespace.@each.capabilities') + get policiesSupportVariableCreation() { + return true; // TODO: check SecureVariables..capabilities[] + } } diff --git a/ui/app/adapters/variable.js b/ui/app/adapters/variable.js new file mode 100644 index 000000000..3ed1d4256 --- /dev/null +++ b/ui/app/adapters/variable.js @@ -0,0 +1,28 @@ +import ApplicationAdapter from './application'; +import { pluralize } from 'ember-inflector'; +import classic from 'ember-classic-decorator'; + +@classic +export default class VariableAdapter extends ApplicationAdapter { + pathForType = () => 'var'; + + // PUT instead of POST on create; + // /v1/var instead of /v1/vars on create (urlForFindRecord) + createRecord(_store, _type, snapshot) { + let data = this.serialize(snapshot); + return this.ajax( + this.urlForFindRecord(snapshot.id, snapshot.modelName), + 'PUT', + { data } + ); + } + + urlForFindAll(modelName) { + let baseUrl = this.buildURL(modelName); + return pluralize(baseUrl); + } + urlForFindRecord(id, modelName, snapshot) { + let baseUrl = this.buildURL(modelName, id, snapshot); + return baseUrl; + } +} diff --git a/ui/app/components/secure-variable-form.hbs b/ui/app/components/secure-variable-form.hbs new file mode 100644 index 000000000..2727e4718 --- /dev/null +++ b/ui/app/components/secure-variable-form.hbs @@ -0,0 +1,71 @@ + +
+ {{!-- TODO: {{if this.parseError 'is-danger'}} on inputs --}} +
+ +
+ {{#each @model.keyValues as |entry iter|}} +
+ + + + + + {{#if (eq entry @model.keyValues.lastObject)}} + + {{else}} + + {{/if}} +
+ {{/each}} +
+ +
+
diff --git a/ui/app/components/secure-variable-form.js b/ui/app/components/secure-variable-form.js new file mode 100644 index 000000000..ade641a1f --- /dev/null +++ b/ui/app/components/secure-variable-form.js @@ -0,0 +1,48 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +export default class SecureVariableFormComponent extends Component { + @service router; + + @tracked + shouldHideValues = true; + + get valueFieldType() { + return this.shouldHideValues ? 'password' : 'text'; + } + + get shouldDisableSave() { + return !this.args.model?.path; + } + + @action + toggleShowHide() { + this.shouldHideValues = !this.shouldHideValues; + } + + @action appendRow() { + this.args.model.keyValues.pushObject({ + key: '', + value: '', + }); + } + + @action deleteRow(row) { + this.args.model.keyValues.removeObject(row); + } + + @action + async save(e) { + e.preventDefault(); + + this.args.model.id = this.args.model.path; + + const transitionTarget = this.args.model.isNew + ? 'variables' + : 'variables.variable'; + + await this.args.model.save(); + this.router.transitionTo(transitionTarget); + } +} diff --git a/ui/app/controllers/variables/index.js b/ui/app/controllers/variables/index.js index 756ebc8a7..18c142667 100644 --- a/ui/app/controllers/variables/index.js +++ b/ui/app/controllers/variables/index.js @@ -1,5 +1,13 @@ import Controller from '@ember/controller'; - +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; export default class VariablesIndexController extends Controller { + @service router; + isForbidden = false; + + @action + goToVariable(variable) { + this.router.transitionTo('variables.variable', variable.path); + } } diff --git a/ui/app/controllers/variables/new.js b/ui/app/controllers/variables/new.js new file mode 100644 index 000000000..cc8595580 --- /dev/null +++ b/ui/app/controllers/variables/new.js @@ -0,0 +1,2 @@ +import Controller from '@ember/controller'; +export default class VariablesNewController extends Controller {} diff --git a/ui/app/controllers/variables/variable.js b/ui/app/controllers/variables/variable.js new file mode 100644 index 000000000..657da6508 --- /dev/null +++ b/ui/app/controllers/variables/variable.js @@ -0,0 +1,10 @@ +import Controller from '@ember/controller'; + +export default class VariablesVariableController extends Controller { + get breadcrumb() { + return { + label: this.model.path, + args: [`variables.variable`, this.model.path], + }; + } +} diff --git a/ui/app/controllers/variables/variable/index.js b/ui/app/controllers/variables/variable/index.js new file mode 100644 index 000000000..573be497f --- /dev/null +++ b/ui/app/controllers/variables/variable/index.js @@ -0,0 +1,3 @@ +import Controller from '@ember/controller'; + +export default class VariablesVariableIndexController extends Controller {} diff --git a/ui/app/models/variable.js b/ui/app/models/variable.js new file mode 100644 index 000000000..d2d0748fa --- /dev/null +++ b/ui/app/models/variable.js @@ -0,0 +1,30 @@ +// @ts-check + +import Model from '@ember-data/model'; +import { attr } from '@ember-data/model'; +import classic from 'ember-classic-decorator'; +// eslint-disable-next-line no-unused-vars +import MutableArray from '@ember/array/mutable'; + +/** + * @typedef SecureVariable + * @type {object} + * @property {string} key + * @property {string} value + */ + +@classic +export default class VariableModel extends Model { + @attr('string') path; + @attr('string') namespace; + + /** + * @type {MutableArray} + */ + @attr({ + defaultValue() { + return [{ key: '', value: '' }]; + }, + }) + keyValues; +} diff --git a/ui/app/modifiers/autofocus.js b/ui/app/modifiers/autofocus.js new file mode 100644 index 000000000..2ede9b50c --- /dev/null +++ b/ui/app/modifiers/autofocus.js @@ -0,0 +1,7 @@ +import { modifier } from 'ember-modifier'; + +export default modifier(function autofocus(element, _positional, named) { + const { ignore } = named; + if (ignore) return; + element.focus(); +}); diff --git a/ui/app/router.js b/ui/app/router.js index e8505876d..0149d77b5 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -78,5 +78,17 @@ Router.map(function () { this.route('evaluations', function () {}); this.route('not-found', { path: '/*' }); - this.route('variables', function () {}); + this.route('variables', function () { + this.route('new'); + + this.route( + 'variable', + { + path: '/*path', + }, + function () { + this.route('edit'); + } + ); + }); }); diff --git a/ui/app/routes/variables.js b/ui/app/routes/variables.js index 918a152ca..64c2dcb5e 100644 --- a/ui/app/routes/variables.js +++ b/ui/app/routes/variables.js @@ -1,8 +1,10 @@ import Route from '@ember/routing/route'; -import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; import { inject as service } from '@ember/service'; +import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import RSVP from 'rsvp'; +import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; -export default class VariablesRoute extends Route.extend(withForbiddenState) { +export default class VariablesRoute extends Route.extend(WithForbiddenState) { @service can; @service router; @@ -12,7 +14,8 @@ export default class VariablesRoute extends Route.extend(withForbiddenState) { } } model() { - // TODO: Populate model from /variables - return {}; + return RSVP.hash({ + variables: this.store.findAll('variable'), + }).catch(notifyForbidden(this)); } } diff --git a/ui/app/routes/variables/index.js b/ui/app/routes/variables/index.js index bcf905a8b..ebde3563c 100644 --- a/ui/app/routes/variables/index.js +++ b/ui/app/routes/variables/index.js @@ -1,17 +1,3 @@ import Route from '@ember/routing/route'; -import withForbiddenState from '../../mixins/with-forbidden-state'; -export default class VariablesIndexRoute extends Route.extend( - withForbiddenState -) { - model() { - // TODO: Fill in model with format from API - return {}; - // return RSVP.hash({ - // variables: this.store - // .query('variable', { namespace: params.qpNamespace }) - // .catch(notifyForbidden(this)), - // namespaces: this.store.findAll('namespace'), - // }); - } -} +export default class VariablesIndexRoute extends Route {} diff --git a/ui/app/routes/variables/new.js b/ui/app/routes/variables/new.js new file mode 100644 index 000000000..c4f2d8431 --- /dev/null +++ b/ui/app/routes/variables/new.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; + +export default class VariablesNewRoute extends Route { + model() { + return this.store.createRecord('variable'); + } + resetController(controller, isExiting) { + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.isNew) { + controller.model.destroyRecord(); + } + } + } +} diff --git a/ui/app/routes/variables/variable.js b/ui/app/routes/variables/variable.js new file mode 100644 index 000000000..746f73d05 --- /dev/null +++ b/ui/app/routes/variables/variable.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; + +export default class VariablesVariableRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + model(params) { + return this.store.findRecord('variable', params.path); + } +} diff --git a/ui/app/routes/variables/variable/edit.js b/ui/app/routes/variables/variable/edit.js new file mode 100644 index 000000000..cd8cdf9ca --- /dev/null +++ b/ui/app/routes/variables/variable/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class VariablesVariableEditRoute extends Route {} diff --git a/ui/app/serializers/variable.js b/ui/app/serializers/variable.js new file mode 100644 index 000000000..8d170ebfc --- /dev/null +++ b/ui/app/serializers/variable.js @@ -0,0 +1,40 @@ +import classic from 'ember-classic-decorator'; +import ApplicationSerializer from './application'; + +@classic +export default class VariableSerializer extends ApplicationSerializer { + primaryKey = 'Path'; + + // Transform API's Items object into an array of a KeyValue objects + normalizeFindRecordResponse(store, typeClass, hash, id, ...args) { + // TODO: prevent items-less saving at API layer + if (!hash.Items) { + hash.Items = { '': '' }; + } + hash.KeyValues = Object.entries(hash.Items).map(([key, value]) => { + return { + key, + value, + }; + }); + delete hash.Items; + return super.normalizeFindRecordResponse( + store, + typeClass, + hash, + id, + ...args + ); + } + + // Transform our KeyValues array into an Items object + serialize(snapshot, options) { + const json = super.serialize(snapshot, options); + json.Items = json.KeyValues.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}); + delete json.KeyValues; + return json; + } +} diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index f644a8e46..8eb2f85e9 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -46,3 +46,4 @@ @import './components/tooltip'; @import './components/two-step-button'; @import './components/evaluations'; +@import './components/secure-variables'; diff --git a/ui/app/styles/components/secure-variables.scss b/ui/app/styles/components/secure-variables.scss new file mode 100644 index 000000000..a73e24e52 --- /dev/null +++ b/ui/app/styles/components/secure-variables.scss @@ -0,0 +1,30 @@ +.new-secure-variables { + & > div { + margin-bottom: 1rem; + } + .key-value { + display: grid; + grid-template-columns: 1fr 4fr 130px; + gap: 1rem; + align-items: end; + + .value-label { + display: grid; + grid-template-columns: 1fr auto; + & > span { + grid-column: -1 / 1; + } + } + + button.show-hide-values { + height: 100%; + box-shadow: none; + margin-left: -2px; + border-color: $grey-blue; + } + } + + // .add-more:focus { + // background-color: $grey-lighter; + // } +} diff --git a/ui/app/templates/variables/index.hbs b/ui/app/templates/variables/index.hbs index bf4052f84..eb3eae2c6 100644 --- a/ui/app/templates/variables/index.hbs +++ b/ui/app/templates/variables/index.hbs @@ -3,13 +3,72 @@ {{#if this.isForbidden}} {{else}} -
-

- No Secure Variables -

-

- Get started by creating a new secure variable -

+
+
+ {{#if this.variables.length}} + + {{/if}} +
+
+
+ {{#if (can "create variable" namespace=this.qpNamespace)}} + + Create Secure Variable + + {{else}} + + {{/if}} + +
+
+ {{#if @model.variables.length}} + + + + Path + + + Namespace + + + + + + {{row.model.path}} + + + {{row.model.namespace}} + + + + + {{else}} +
+

+ No Secure Variables +

+

+ Get started by creating a new secure variable +

+
+ {{/if}} {{/if}} diff --git a/ui/app/templates/variables/new.hbs b/ui/app/templates/variables/new.hbs new file mode 100644 index 000000000..8981a93e0 --- /dev/null +++ b/ui/app/templates/variables/new.hbs @@ -0,0 +1,5 @@ +{{page-title "New Secure Variable"}} + +
+ +
diff --git a/ui/app/templates/variables/variable.hbs b/ui/app/templates/variables/variable.hbs new file mode 100644 index 000000000..e892a4e80 --- /dev/null +++ b/ui/app/templates/variables/variable.hbs @@ -0,0 +1,6 @@ +{{page-title "Secure Variables: " this.model.path}} + +
+ {{outlet}} +
+ \ No newline at end of file diff --git a/ui/app/templates/variables/variable/edit.hbs b/ui/app/templates/variables/variable/edit.hbs new file mode 100644 index 000000000..e6c58b8a8 --- /dev/null +++ b/ui/app/templates/variables/variable/edit.hbs @@ -0,0 +1,5 @@ +{{page-title "Edit Secure Variable"}} + +
+ +
diff --git a/ui/app/templates/variables/variable/index.hbs b/ui/app/templates/variables/variable/index.hbs new file mode 100644 index 000000000..b59a4e4eb --- /dev/null +++ b/ui/app/templates/variables/variable/index.hbs @@ -0,0 +1,15 @@ + + Edit + + + + + {{row.model.key}} + {{row.model.value}} + + + diff --git a/ui/jsconfig.json b/ui/jsconfig.json index 8d067cec4..6c9d2bc42 100644 --- a/ui/jsconfig.json +++ b/ui/jsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { - "experimentalDecorators": true - } + "experimentalDecorators": true, + "target": "es5", + }, + } \ No newline at end of file diff --git a/ui/mirage/config.js b/ui/mirage/config.js index f048b8311..09d55b560 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -835,6 +835,27 @@ export default function () { return {}; } ); + + //#region Secure Variables + this.get('/vars', function (schema) { + return schema.variables.all(); + }); + this.get('/var/:id', function ({ variables }, { params }) { + return variables.find(params.id); + }); + this.put('/var/:id', function (schema, request) { + const { Path, Namespace, Items } = JSON.parse(request.requestBody); + server.create('variable', { + Path, + Namespace, + Items, + id: Path, + }); + + return okEmpty(); // TODO: consider returning the created object. + }); + + //#endregion Secure Variables } function filterKeys(object, ...keys) { diff --git a/ui/mirage/factories/variable.js b/ui/mirage/factories/variable.js new file mode 100644 index 000000000..8a98ea18f --- /dev/null +++ b/ui/mirage/factories/variable.js @@ -0,0 +1,21 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; + +export default Factory.extend({ + id: () => faker.random.words(3).split(' ').join('/').toLowerCase(), + path() { + return this.id; + }, + namespace: 'default', + items() { + return ( + this.Items || { + [faker.database.column()]: faker.database.collation(), + [faker.database.column()]: faker.database.collation(), + [faker.database.column()]: faker.database.collation(), + [faker.database.column()]: faker.database.collation(), + [faker.database.column()]: faker.database.collation(), + } + ); + }, +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 424bd7af9..f69393fd3 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -50,6 +50,7 @@ function smallCluster(server) { server.createList('allocFile', 5); server.create('allocFile', 'dir', { depth: 2 }); server.createList('csi-plugin', 2); + server.createList('variable', 3); // #region evaluations diff --git a/ui/package.json b/ui/package.json index 52881ae2c..955ef5cc2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -170,6 +170,7 @@ ] }, "dependencies": { + "@hashicorp/ember-flight-icons": "^2.0.5", "@percy/cli": "^1.1.0", "@percy/ember": "^3.0.0", "codemirror": "^5.56.0", diff --git a/ui/tests/acceptance/secure-variables-test.js b/ui/tests/acceptance/secure-variables-test.js index f65a519b7..d610add51 100644 --- a/ui/tests/acceptance/secure-variables-test.js +++ b/ui/tests/acceptance/secure-variables-test.js @@ -13,41 +13,41 @@ const SECURE_TOKEN_ID = '53cur3-v4r14bl35'; module('Acceptance | secure variables', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + hooks.beforeEach(async function () { + server.createList('variable', 3); + }); - module('Guarding page access', function () { - test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) { - await Variables.visit(); - assert.equal(currentURL(), '/jobs'); - assert.ok(Layout.gutter.variables.isHidden); - }); + test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) { + await Variables.visit(); + assert.equal(currentURL(), '/jobs'); + assert.ok(Layout.gutter.variables.isHidden); + }); - test('it allows access for management level tokens', async function (assert) { - defaultScenario(server); - window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + test('it allows access for management level tokens', async function (assert) { + defaultScenario(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await Variables.visit(); + assert.equal(currentURL(), '/variables'); + assert.ok(Layout.gutter.variables.isVisible, 'Menu section is visible'); + }); - await Variables.visit(); - assert.equal(currentURL(), '/variables'); - assert.ok(Layout.gutter.variables.isVisible); - }); + test('it allows access for list-variables allowed ACL rules', async function (assert) { + assert.expect(2); + defaultScenario(server); + const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); + window.localStorage.nomadTokenSecret = variablesToken.secretId; - test('it allows access for list-variables allowed ACL rules', async function (assert) { - assert.expect(2); - defaultScenario(server); - const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); - window.localStorage.nomadTokenSecret = variablesToken.secretId; + await Variables.visit(); + assert.equal(currentURL(), '/variables'); + assert.ok(Layout.gutter.variables.isVisible); + }); - await Variables.visit(); - assert.equal(currentURL(), '/variables'); - assert.ok(Layout.gutter.variables.isVisible); - }); - - test('it passes an accessibility audit', async function (assert) { - assert.expect(1); - defaultScenario(server); - const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); - window.localStorage.nomadTokenSecret = variablesToken.secretId; - await Variables.visit(); - await a11yAudit(assert); - }); + test('it passes an accessibility audit', async function (assert) { + assert.expect(1); + defaultScenario(server); + const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); + window.localStorage.nomadTokenSecret = variablesToken.secretId; + await Variables.visit(); + await a11yAudit(assert); }); }); diff --git a/ui/tests/integration/components/secure-variable-form-test.js b/ui/tests/integration/components/secure-variable-form-test.js new file mode 100644 index 000000000..8549c0452 --- /dev/null +++ b/ui/tests/integration/components/secure-variable-form-test.js @@ -0,0 +1,147 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; +import { click, findAll, render } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Integration | Component | secure-variable-form', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + test('passes an accessibility audit', async function (assert) { + assert.expect(1); + await render(hbs``); + await componentA11yAudit(this.element, assert); + }); + + test('shows a single row by default and modifies on "Add More" and "Delete"', async function (assert) { + this.set( + 'mockedModel', + server.create('variable', { + keyValues: [{ key: '', value: '' }], + }) + ); + assert.expect(4); + + await render(hbs``); + assert.equal( + findAll('div.key-value').length, + 1, + 'A single KV row exists by default' + ); + + await click('.key-value button.add-more'); + + assert.equal( + findAll('div.key-value').length, + 2, + 'A second KV row exists after adding a new one' + ); + + await click('.key-value button.add-more'); + + assert.equal( + findAll('div.key-value').length, + 3, + 'A third KV row exists after adding a new one' + ); + + await click('.key-value button.delete-row'); + + assert.equal( + findAll('div.key-value').length, + 2, + 'Back down to two rows after hitting delete' + ); + }); + + test('Values can be toggled to show/hide', async function (assert) { + this.set( + 'mockedModel', + server.create('variable', { + keyValues: [{ key: '', value: '' }], + }) + ); + + assert.expect(6); + + await render(hbs``); + await click('.key-value button.add-more'); // add a second variable + + findAll('input.value-input').forEach((input, iter) => { + assert.equal( + input.getAttribute('type'), + 'password', + `Value ${iter + 1} is hidden by default` + ); + }); + + await click('.key-value button.show-hide-values'); + findAll('input.value-input').forEach((input, iter) => { + assert.equal( + input.getAttribute('type'), + 'text', + `Value ${iter + 1} is shown when toggled` + ); + }); + + await click('.key-value button.show-hide-values'); + findAll('input.value-input').forEach((input, iter) => { + assert.equal( + input.getAttribute('type'), + 'password', + `Value ${iter + 1} is hidden when toggled again` + ); + }); + }); + + test('Existing variable shows properties by default', async function (assert) { + assert.expect(13); + const keyValues = [ + { key: 'my-completely-normal-key', value: 'never' }, + { key: 'another key, but with spaces', value: 'gonna' }, + { key: 'once/more/with/slashes', value: 'give' }, + { key: 'and_some_underscores', value: 'you' }, + { key: 'and\\now/for-something_completely@different', value: 'up' }, + ]; + + this.set( + 'mockedModel', + server.create('variable', { + path: 'my/path/to', + keyValues, + }) + ); + await render(hbs``); + assert.equal( + findAll('div.key-value').length, + 5, + 'Shows 5 existing key values' + ); + assert.equal( + findAll('button.delete-row').length, + 4, + 'Shows "delete" for the first four rows' + ); + assert.equal( + findAll('button.add-more').length, + 1, + 'Shows "add more" only on the last row' + ); + + findAll('div.key-value').forEach((row, idx) => { + assert.equal( + row.querySelector(`label:nth-child(1) input`).value, + keyValues[idx].key, + `Key ${idx + 1} is correct` + ); + + assert.equal( + row.querySelector(`label:nth-child(2) input`).value, + keyValues[idx].value, + keyValues[idx].value + ); + }); + }); +}); diff --git a/ui/tests/integration/modifiers/autofocus-test.js b/ui/tests/integration/modifiers/autofocus-test.js new file mode 100644 index 000000000..715d787d5 --- /dev/null +++ b/ui/tests/integration/modifiers/autofocus-test.js @@ -0,0 +1,68 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Modifier | autofocus', function (hooks) { + setupRenderingTest(hooks); + + test('Basic Usage', async function (assert) { + await render(hbs` +
+ +
`); + + assert + .dom('[data-test-input-1]') + .isFocused('Autofocus on an element works'); + }); + + test('Multiple foci', async function (assert) { + await render(hbs` +
+ + +
`); + + assert + .dom('[data-test-input-1]') + .isNotFocused('With multiple autofocus elements, priors are unfocused'); + assert + .dom('[data-test-input-2]') + .isFocused('With multiple autofocus elements, posteriors are focused'); + }); + + test('Ignore parameter', async function (assert) { + await render(hbs` +
+ + + + +
`); + + assert + .dom('[data-test-input-2]') + .isFocused('The last autofocus element without ignore is focused'); + assert + .dom('[data-test-input-3]') + .isNotFocused('Ignore parameter is observed, prior'); + assert + .dom('[data-test-input-4]') + .isNotFocused('Ignore parameter is observed, posterior'); + }); +}); diff --git a/ui/tests/unit/abilities/variable-test.js b/ui/tests/unit/abilities/variable-test.js index 0fa34a4d0..1908b4773 100644 --- a/ui/tests/unit/abilities/variable-test.js +++ b/ui/tests/unit/abilities/variable-test.js @@ -7,98 +7,95 @@ import setupAbility from 'nomad-ui/tests/helpers/setup-ability'; module('Unit | Ability | variable', function (hooks) { setupTest(hooks); setupAbility('variable')(hooks); - - module('when the Variables feature is not present', function (hooks) { - hooks.beforeEach(function () { - const mockSystem = Service.extend({ - features: [], - }); - - this.owner.register('service:system', mockSystem); + hooks.beforeEach(function () { + const mockSystem = Service.extend({ + features: [], }); - test('it does not permit listing variables by default', function (assert) { - const mockToken = Service.extend({ - aclEnabled: true, - }); + this.owner.register('service:system', mockSystem); + }); - this.owner.register('service:token', mockToken); - - assert.notOk(this.ability.canList); + test('it does not permit listing variables by default', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, }); - test('it does not permit listing variables when token type is client', function (assert) { - const mockToken = Service.extend({ - aclEnabled: true, - selfToken: { type: 'client' }, - }); + this.owner.register('service:token', mockToken); - this.owner.register('service:token', mockToken); + assert.notOk(this.ability.canList); + }); - assert.notOk(this.ability.canList); + test('it does not permit listing variables when token type is client', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, }); - test('it permits listing variables when token type is management', function (assert) { - const mockToken = Service.extend({ - aclEnabled: true, - selfToken: { type: 'management' }, - }); + this.owner.register('service:token', mockToken); - this.owner.register('service:token', mockToken); + assert.notOk(this.ability.canList); + }); - assert.ok(this.ability.canList); + test('it permits listing variables when token type is management', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'management' }, }); - test('it permits listing variables when token has SecureVariables with list capabilities in its rules', function (assert) { - const mockToken = Service.extend({ - aclEnabled: true, - selfToken: { type: 'client' }, - selfTokenPolicies: [ - { - rulesJSON: { - Namespaces: [ - { - Name: 'default', - Capabilities: [], - SecureVariables: { - 'Path "*"': { - Capabilities: ['list'], - }, + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canList); + }); + + test('it permits listing variables when token has SecureVariables with list capabilities in its rules', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'default', + Capabilities: [], + SecureVariables: { + 'Path "*"': { + Capabilities: ['list'], }, }, - ], - }, + }, + ], }, - ], - }); - - this.owner.register('service:token', mockToken); - - assert.ok(this.ability.canList); + }, + ], }); - test('it permits listing variables when token has SecureVariables alone in its rules', function (assert) { - const mockToken = Service.extend({ - aclEnabled: true, - selfToken: { type: 'client' }, - selfTokenPolicies: [ - { - rulesJSON: { - Namespaces: [ - { - Name: 'default', - Capabilities: [], - SecureVariables: {}, - }, - ], - }, + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canList); + }); + + test('it permits listing variables when token has SecureVariables alone in its rules', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'default', + Capabilities: [], + SecureVariables: {}, + }, + ], }, - ], - }); - - this.owner.register('service:token', mockToken); - - assert.ok(this.ability.canList); + }, + ], }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canList); }); }); diff --git a/ui/tests/unit/adapters/variable-test.js b/ui/tests/unit/adapters/variable-test.js new file mode 100644 index 000000000..8d0e8ab81 --- /dev/null +++ b/ui/tests/unit/adapters/variable-test.js @@ -0,0 +1,24 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Adapter | Variable', function (hooks) { + setupTest(hooks); + + test('Correctly pluralizes lookups with shortened path', async function (assert) { + this.store = this.owner.lookup('service:store'); + this.subject = () => this.store.adapterFor('variable'); + + let newVariable = await this.store.createRecord('variable'); + + assert.equal( + this.subject().urlForFindAll('variable'), + '/v1/vars', + 'pluralizes findAll lookup' + ); + assert.equal( + this.subject().urlForFindRecord('foo/bar', 'variable', newVariable), + `/v1/var/${encodeURIComponent('foo/bar')}`, + 'singularizes findRecord lookup' + ); + }); +}); diff --git a/ui/tests/unit/models/variable-test.js b/ui/tests/unit/models/variable-test.js new file mode 100644 index 000000000..e39e2316a --- /dev/null +++ b/ui/tests/unit/models/variable-test.js @@ -0,0 +1,33 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Model | variable', function (hooks) { + setupTest(hooks); + + test('it has basic fetchable properties', function (assert) { + let store = this.owner.lookup('service:store'); + + let model = store.createRecord('variable'); + model.setProperties({ + path: 'my/fun/path', + namespace: 'default', + keyValues: [ + { key: 'foo', value: 'bar' }, + { key: 'myVar', value: 'myValue' }, + ], + }); + assert.ok(model.path); + assert.equal(model.keyValues.length, 2); + }); + + test('it has a single keyValue by default', function (assert) { + let store = this.owner.lookup('service:store'); + + let model = store.createRecord('variable'); + model.setProperties({ + path: 'my/fun/path', + namespace: 'default', + }); + assert.equal(model.keyValues.length, 1); + }); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 7771b59ab..900478fae 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -3085,6 +3085,20 @@ resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf" integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A== +"@hashicorp/ember-flight-icons@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-2.0.5.tgz#a1edfdd24475ecd0cf07cd1f944e2e5bdb5e97cc" + integrity sha512-PXNk1aRBjYSGeoB4e2ovOBm6RhGKE554XjW8leYYK+y9yorHhJNNwWRkwjhDRLYWikLhNmfwp6nAYOJWl/IOgw== + dependencies: + "@hashicorp/flight-icons" "^2.3.1" + ember-cli-babel "^7.26.11" + ember-cli-htmlbars "^6.0.1" + +"@hashicorp/flight-icons@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.3.1.tgz#0b0dc259c0a4255c5613174db7192ab48523ca6f" + integrity sha512-WGCMMixkmYCP5Dyz4QW7XjW4zDhIc7njkVVucoj7Iv7abtfgQDWwm05Ja2aBJTxFHiP4jat9w9cbGNgC6QHmZQ== + "@hashicorp/structure-icons@^1.3.0": version "1.9.2" resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c"