From 7442901acaa6331871210d2a7f06ebb1e7769be7 Mon Sep 17 00:00:00 2001 From: Phil Renaud <phil.renaud@hashicorp.com> Date: Mon, 6 Jun 2022 21:42:23 -0400 Subject: [PATCH] Secure Variables: Build a path tree and traverse it at /variables/*path (#13202) * Recursive trie-building with variable paths * tree structure applied to new path routes and a new util class * Breadcrumbs for SV paths and prompt when nothing exists at a path * Lint and test cleanup * Pre-review cleanup * lintfix * Abstracted pathtree each-ins into a new component class * Path tree component styles * Types added and PR feedback addressed * Path tree to variable paths * Slightly simpler path QP mods * More pr feedback handling * Trim moved into a function on variable model * Traversal and compaction tests for PathTree * Trim Path tests * Variable-paths component tests * Lint fixup for tests --- ui/app/components/secure-variable-form.js | 3 +- ui/app/components/variable-paths.hbs | 36 +++++ ui/app/components/variable-paths.js | 31 +++++ ui/app/controllers/variables/new.js | 4 +- ui/app/controllers/variables/path.js | 15 +++ ui/app/controllers/variables/variable.js | 18 ++- ui/app/helpers/trim-path.js | 19 +++ ui/app/models/variable.js | 42 +++++- ui/app/router.js | 4 + ui/app/routes/variables.js | 19 ++- ui/app/routes/variables/new.js | 6 +- ui/app/routes/variables/path.js | 13 ++ ui/app/routes/variables/variable.js | 2 +- .../styles/components/secure-variables.scss | 16 ++- ui/app/templates/variables/index.hbs | 111 +++++++--------- ui/app/templates/variables/new.hbs | 2 +- ui/app/templates/variables/path.hbs | 46 +++++++ ui/app/templates/variables/variable.hbs | 4 +- ui/app/utils/path-tree.js | 123 ++++++++++++++++++ ui/mirage/scenarios/default.js | 17 +++ .../components/variable-paths-test.js | 106 +++++++++++++++ .../integration/helpers/trim-path-test.js | 29 +++++ ui/tests/unit/utils/path-tree-test.js | 98 ++++++++++++++ 23 files changed, 671 insertions(+), 93 deletions(-) create mode 100644 ui/app/components/variable-paths.hbs create mode 100644 ui/app/components/variable-paths.js create mode 100644 ui/app/controllers/variables/path.js create mode 100644 ui/app/helpers/trim-path.js create mode 100644 ui/app/routes/variables/path.js create mode 100644 ui/app/templates/variables/path.hbs create mode 100644 ui/app/utils/path-tree.js create mode 100644 ui/tests/integration/components/variable-paths-test.js create mode 100644 ui/tests/integration/helpers/trim-path-test.js create mode 100644 ui/tests/unit/utils/path-tree-test.js diff --git a/ui/app/components/secure-variable-form.js b/ui/app/components/secure-variable-form.js index ade641a1fd..c2ef9f3983 100644 --- a/ui/app/components/secure-variable-form.js +++ b/ui/app/components/secure-variable-form.js @@ -35,8 +35,7 @@ export default class SecureVariableFormComponent extends Component { @action async save(e) { e.preventDefault(); - - this.args.model.id = this.args.model.path; + this.args.model.setAndTrimPath(); const transitionTarget = this.args.model.isNew ? 'variables' diff --git a/ui/app/components/variable-paths.hbs b/ui/app/components/variable-paths.hbs new file mode 100644 index 0000000000..bdda12c3ce --- /dev/null +++ b/ui/app/components/variable-paths.hbs @@ -0,0 +1,36 @@ +<ListTable class="path-tree" @source={{@branch}} as |t|> + <t.head> + <th> + Path + </th> + </t.head> + <tbody> + {{#each this.folders as |folder|}} + <tr data-test-folder-row {{on "click" (fn this.handleFolderClick folder.data.absolutePath)}}> + <td> + <span> + <FlightIcon @name="folder" /> + <LinkTo @route="variables.path" @model={{folder.data.absolutePath}}> + {{trim-path folder.name}} + </LinkTo> + </span> + </td> + </tr> + {{/each}} + + {{#each this.files as |file|}} + <tr data-test-file-row {{on "click" (fn this.handleFileClick file.absoluteFilePath)}}> + <td> + <FlightIcon @name="file-text" /> + <LinkTo + @route="variables.variable" + @model={{file.absoluteFilePath}} + > + {{file.name}} + </LinkTo> + </td> + </tr> + {{/each}} + + </tbody> + </ListTable> diff --git a/ui/app/components/variable-paths.js b/ui/app/components/variable-paths.js new file mode 100644 index 0000000000..f5a4491186 --- /dev/null +++ b/ui/app/components/variable-paths.js @@ -0,0 +1,31 @@ +// @ts-check +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class VariablePathsComponent extends Component { + @service router; + + /** + * @returns {Array<Object.<string, Object>>} + */ + get folders() { + return Object.entries(this.args.branch.children).map(([name, data]) => { + return { name, data }; + }); + } + + get files() { + return this.args.branch.files; + } + + @action + async handleFolderClick(path) { + this.router.transitionTo('variables.path', path); + } + + @action + async handleFileClick(path) { + this.router.transitionTo('variables.variable', path); + } +} diff --git a/ui/app/controllers/variables/new.js b/ui/app/controllers/variables/new.js index cc85955804..a33e5a5b98 100644 --- a/ui/app/controllers/variables/new.js +++ b/ui/app/controllers/variables/new.js @@ -1,2 +1,4 @@ import Controller from '@ember/controller'; -export default class VariablesNewController extends Controller {} +export default class VariablesNewController extends Controller { + queryParams = ['path']; +} diff --git a/ui/app/controllers/variables/path.js b/ui/app/controllers/variables/path.js new file mode 100644 index 0000000000..7c59d3a15b --- /dev/null +++ b/ui/app/controllers/variables/path.js @@ -0,0 +1,15 @@ +import Controller from '@ember/controller'; + +export default class VariablesPathController extends Controller { + get breadcrumbs() { + let crumbs = []; + this.model.absolutePath.split('/').reduce((m, n) => { + crumbs.push({ + label: n, + args: [`variables.path`, m + n], + }); + return m + n + '/'; + }, []); + return crumbs; + } +} diff --git a/ui/app/controllers/variables/variable.js b/ui/app/controllers/variables/variable.js index 657da6508f..3fd68ad8f9 100644 --- a/ui/app/controllers/variables/variable.js +++ b/ui/app/controllers/variables/variable.js @@ -1,10 +1,18 @@ import Controller from '@ember/controller'; export default class VariablesVariableController extends Controller { - get breadcrumb() { - return { - label: this.model.path, - args: [`variables.variable`, this.model.path], - }; + get breadcrumbs() { + let crumbs = []; + this.model.path.split('/').reduce((m, n) => { + crumbs.push({ + label: n, + args: + m + n === this.model.path // If the last crumb, link to the var itself + ? [`variables.variable`, m + n] + : [`variables.path`, m + n], + }); + return m + n + '/'; + }, []); + return crumbs; } } diff --git a/ui/app/helpers/trim-path.js b/ui/app/helpers/trim-path.js new file mode 100644 index 0000000000..c9eb495c90 --- /dev/null +++ b/ui/app/helpers/trim-path.js @@ -0,0 +1,19 @@ +// @ts-check +import Helper from '@ember/component/helper'; + +/** + * Trims any number of slashes from the beginning and end of a string. + * @param {Array<string>} params + * @returns {string} + */ +export function trimPath([path = '']) { + if (path.startsWith('/')) { + path = trimPath([path.slice(1)]); + } + if (path.endsWith('/')) { + path = trimPath([path.slice(0, -1)]); + } + return path; +} + +export default Helper.helper(trimPath); diff --git a/ui/app/models/variable.js b/ui/app/models/variable.js index d2d0748faf..c40750b191 100644 --- a/ui/app/models/variable.js +++ b/ui/app/models/variable.js @@ -1,25 +1,40 @@ // @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'; +import { trimPath } from '../helpers/trim-path'; /** - * @typedef SecureVariable + * @typedef KeyValue * @type {object} * @property {string} key * @property {string} value */ +/** + * @typedef SecureVariable + * @type {object} + */ + +/** + * A Secure Variable has a path, namespace, and an array of key-value pairs within the client. + * On the server, these key-value pairs are serialized into object structure. + * @class + * @extends Model + */ @classic export default class VariableModel extends Model { + /** + * Can be any arbitrary string, but behaves best when used as a slash-delimited file path. + * + * @type {string} + */ @attr('string') path; - @attr('string') namespace; /** - * @type {MutableArray<SecureVariable>} + * @type {MutableArray<KeyValue>} */ @attr({ defaultValue() { @@ -27,4 +42,23 @@ export default class VariableModel extends Model { }, }) keyValues; + + /** @type {number} */ + @attr('number') createIndex; + /** @type {number} */ + @attr('number') modifyIndex; + /** @type {string} */ + @attr('string') createTime; + /** @type {string} */ + @attr('string') modifyTime; + /** @type {string} */ + @attr('string') namespace; + + /** + * Removes starting and trailing slashes, and sets the ID property + */ + setAndTrimPath() { + this.path = trimPath([this.path]); + this.id = this.path; + } } diff --git a/ui/app/router.js b/ui/app/router.js index 0149d77b50..01ee4136d7 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -90,5 +90,9 @@ Router.map(function () { this.route('edit'); } ); + + this.route('path', { + path: '/path/*absolutePath', + }); }); }); diff --git a/ui/app/routes/variables.js b/ui/app/routes/variables.js index 64c2dcb5eb..0ee2cfde3a 100644 --- a/ui/app/routes/variables.js +++ b/ui/app/routes/variables.js @@ -1,21 +1,28 @@ import Route from '@ember/routing/route'; 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'; +import notifyError from 'nomad-ui/utils/notify-error'; +import PathTree from 'nomad-ui/utils/path-tree'; export default class VariablesRoute extends Route.extend(WithForbiddenState) { @service can; @service router; + @service store; beforeModel() { if (this.can.cannot('list variables')) { this.router.transitionTo('/jobs'); } } - model() { - return RSVP.hash({ - variables: this.store.findAll('variable'), - }).catch(notifyForbidden(this)); + async model() { + try { + const variables = await this.store.findAll('variable'); + return { + variables, + pathTree: new PathTree(variables), + }; + } catch (e) { + notifyError(this)(e); + } } } diff --git a/ui/app/routes/variables/new.js b/ui/app/routes/variables/new.js index c4f2d84311..e8415f8026 100644 --- a/ui/app/routes/variables/new.js +++ b/ui/app/routes/variables/new.js @@ -1,10 +1,12 @@ import Route from '@ember/routing/route'; export default class VariablesNewRoute extends Route { - model() { - return this.store.createRecord('variable'); + model(params) { + return this.store.createRecord('variable', { path: params.path }); } resetController(controller, isExiting) { + // If the user navigates away from /new, clear the path + controller.set('path', null); if (isExiting) { // If user didn't save, delete the freshly created model if (controller.model.isNew) { diff --git a/ui/app/routes/variables/path.js b/ui/app/routes/variables/path.js new file mode 100644 index 0000000000..7e0c51b7fa --- /dev/null +++ b/ui/app/routes/variables/path.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; + +export default class VariablesPathRoute extends Route { + model({ absolutePath }) { + const treeAtPath = + this.modelFor('variables').pathTree.findPath(absolutePath); + if (treeAtPath) { + return { treeAtPath, absolutePath }; + } else { + return { absolutePath }; + } + } +} diff --git a/ui/app/routes/variables/variable.js b/ui/app/routes/variables/variable.js index 746f73d059..f2c4ef26c9 100644 --- a/ui/app/routes/variables/variable.js +++ b/ui/app/routes/variables/variable.js @@ -7,6 +7,6 @@ export default class VariablesVariableRoute extends Route.extend( WithModelErrorHandling ) { model(params) { - return this.store.findRecord('variable', params.path); + return this.store.findRecord('variable', decodeURIComponent(params.path)); } } diff --git a/ui/app/styles/components/secure-variables.scss b/ui/app/styles/components/secure-variables.scss index a73e24e524..524735fd40 100644 --- a/ui/app/styles/components/secure-variables.scss +++ b/ui/app/styles/components/secure-variables.scss @@ -23,8 +23,18 @@ border-color: $grey-blue; } } +} - // .add-more:focus { - // background-color: $grey-lighter; - // } +table.path-tree { + tr { + cursor: pointer; + a { + color: #0a0a0a; + text-decoration: none; + } + svg { + margin-bottom: -2px; + margin-right: 10px; + } + } } diff --git a/ui/app/templates/variables/index.hbs b/ui/app/templates/variables/index.hbs index eb3eae2c6d..9ff75dc403 100644 --- a/ui/app/templates/variables/index.hbs +++ b/ui/app/templates/variables/index.hbs @@ -1,74 +1,51 @@ {{page-title "Secure Variables"}} <section class="section"> - {{#if this.isForbidden}} - <ForbiddenMessage /> - {{else}} - <div class="toolbar"> - <div class="toolbar-item"> - {{#if this.variables.length}} - <SearchBox - @searchTerm={{mut this.searchTerm}} - @onChange={{action this.resetPagination}} - @placeholder="Search variables..." - /> - {{/if}} - </div> - <div class="toolbar-item is-right-aligned is-mobile-full-width"> - <div class="button-bar"> - {{#if (can "create variable" namespace=this.qpNamespace)}} - <LinkTo - @route="variables.new" - @query={{hash namespace=this.qpNamespace}} - data-test-run-job - class="button is-primary" - > - Create Secure Variable - </LinkTo> - {{else}} - <button - data-test-run-job - class="button is-primary is-disabled tooltip is-right-aligned" - aria-label="You don鈥檛 have sufficient permissions" - disabled - type="button" - > - Create Secure Variable - </button> - {{/if}} + <div class="toolbar"> + <div class="toolbar-item"> + {{#if this.variables.length}} + <SearchBox + @searchTerm={{mut this.searchTerm}} + @onChange={{action this.resetPagination}} + @placeholder="Search variables..." + /> + {{/if}} + </div> + <div class="toolbar-item is-right-aligned is-mobile-full-width"> + <div class="button-bar"> + {{#if (can "create variable" namespace=this.qpNamespace)}} + <LinkTo + @route="variables.new" + @query={{hash namespace=this.qpNamespace}} + class="button is-primary" + > + Create Secure Variable + </LinkTo> + {{else}} + <button + class="button is-primary is-disabled tooltip is-right-aligned" + aria-label="You don鈥檛 have sufficient permissions" + disabled + type="button" + > + Create Secure Variable + </button> + {{/if}} - </div> </div> </div> - {{#if @model.variables.length}} - <ListTable data-test-eval-table @source={{@model.variables}} as |t|> - <t.head> - <th> - Path - </th> - <th> - Namespace - </th> - </t.head> - <t.body as |row|> - <tr {{on "click" (fn this.goToVariable row.model)}}> - <td> - {{row.model.path}} - </td> - <td> - {{row.model.namespace}} - </td> - </tr> - </t.body> - </ListTable> - {{else}} - <div class="empty-message"> - <h3 data-test-empty-volumes-list-headline class="empty-message-headline"> - No Secure Variables - </h3> - <p class="empty-message-body"> - Get started by <LinkTo @route="variables.new">creating a new secure variable</LinkTo> - </p> - </div> - {{/if}} + </div> + {{#if @model.variables.length}} + <VariablePaths + @branch={{this.model.pathTree.paths.root}} + /> + {{else}} + <div class="empty-message"> + <h3 data-test-empty-volumes-list-headline class="empty-message-headline"> + No Secure Variables + </h3> + <p class="empty-message-body"> + Get started by <LinkTo @route="variables.new">creating a new secure variable</LinkTo> + </p> + </div> {{/if}} </section> diff --git a/ui/app/templates/variables/new.hbs b/ui/app/templates/variables/new.hbs index 8981a93e04..e2a3946ceb 100644 --- a/ui/app/templates/variables/new.hbs +++ b/ui/app/templates/variables/new.hbs @@ -1,5 +1,5 @@ {{page-title "New Secure Variable"}} <Breadcrumb @crumb={{hash label="New" args=(array "variables.new")}} /> <section class="section"> - <SecureVariableForm @model={{this.model}} /> + <SecureVariableForm @model={{this.model}} @path={{this.path}} /> </section> diff --git a/ui/app/templates/variables/path.hbs b/ui/app/templates/variables/path.hbs new file mode 100644 index 0000000000..6c72fc3c8f --- /dev/null +++ b/ui/app/templates/variables/path.hbs @@ -0,0 +1,46 @@ +{{page-title "Secure Variables: " this.model.absolutePath}} +{{#each this.breadcrumbs as |crumb|}} + <Breadcrumb @crumb={{crumb}} /> +{{/each}} + <section class="section"> + <div class="toolbar"> + <div class="toolbar-item is-right-aligned is-mobile-full-width"> + <div class="button-bar"> + {{!-- TODO: make sure qpNamespace persists to here --}} + {{#if (can "create variable" namespace=this.qpNamespace)}} + <LinkTo + @route="variables.new" + @query={{hash namespace=this.qpNamespace path=(concat this.model.absolutePath "/")}} + class="button is-primary" + > + Create Secure Variable + </LinkTo> + {{else}} + <button + class="button is-primary is-disabled tooltip is-right-aligned" + aria-label="You don鈥檛 have sufficient permissions" + disabled + type="button" + > + Create Secure Variable + </button> + {{/if}} + + </div> + </div> + </div> +{{#if this.model.treeAtPath}} + <VariablePaths + @branch={{this.model.treeAtPath}} + /> +{{else}} + <div class="empty-message"> + <h3 data-test-empty-volumes-list-headline class="empty-message-headline"> + Path /{{this.model.absolutePath}} contains no variables + </h3> + <p class="empty-message-body"> + To get started, <LinkTo @route="variables.new" @query={{hash path=(concat this.model.absolutePath "/")}}>create a new secure variable here</LinkTo>, or <LinkTo @route="variables">go back to the Secure Variables root directory</LinkTo>. + </p> + </div> +{{/if}} + </section> diff --git a/ui/app/templates/variables/variable.hbs b/ui/app/templates/variables/variable.hbs index e892a4e809..4a50209c39 100644 --- a/ui/app/templates/variables/variable.hbs +++ b/ui/app/templates/variables/variable.hbs @@ -1,5 +1,7 @@ {{page-title "Secure Variables: " this.model.path}} -<Breadcrumb @crumb={{this.breadcrumb}} /> +{{#each this.breadcrumbs as |crumb|}} + <Breadcrumb @crumb={{crumb}} /> +{{/each}} <section class="section"> {{outlet}} </section> diff --git a/ui/app/utils/path-tree.js b/ui/app/utils/path-tree.js new file mode 100644 index 0000000000..1d1db43c79 --- /dev/null +++ b/ui/app/utils/path-tree.js @@ -0,0 +1,123 @@ +// @ts-check + +// eslint-disable-next-line no-unused-vars +import VariableModel from '../models/variable'; +// eslint-disable-next-line no-unused-vars +import MutableArray from '@ember/array/mutable'; +import { trimPath } from '../helpers/trim-path'; + +//#region Types +/** + * @typedef {Object} VariablePathObject + * @property {string} path - the folder path containing our "file", relative to parent + * @property {string} name - the secure variable "file" name + * @property {string} [absoluteFilePath] - the folder path containing our "file", absolute + * @property {string} [absolutePath] - the folder path containing our "file", absolute + */ + +/** + * @typedef {Object.<string, Object>} NestedPathTreeNode + */ +//#endregion Types + +/** + * Turns a file path into an object with file and path properties. + * @param {string} path - the file path + * @return {VariablePathObject} + */ +function PATH_TO_OBJECT(path) { + const split = path.split('/'); + const [name, ...folderPath] = [split.pop(), ...split]; + return { + name, + absoluteFilePath: path, + path: folderPath.join('/'), + }; +} + +/** + * Compacts an object of path:file key-values so any same-common-ancestor paths are collapsed into a single path. + * @param {NestedPathTreeNode} vars + * @returns {void}} + */ +function COMPACT_EMPTY_DIRS(vars) { + Object.keys(vars).map((pathString) => { + const encompasser = Object.keys(vars).find( + (ps) => ps !== pathString && pathString.startsWith(ps) + ); + if (encompasser) { + vars[encompasser].children[pathString.replace(encompasser, '')] = + vars[pathString]; + delete vars[pathString]; + COMPACT_EMPTY_DIRS(vars[encompasser].children); + } + }); +} + +/** + * @returns {NestedPathTreeNode} + */ +export default class PathTree { + /** + * @param {MutableArray<VariableModel>} variables + */ + constructor(variables) { + this.variables = variables; + this.paths = this.generatePaths(); + } + + /** + * Takes our variables array and groups them by common path + * @returns {NestedPathTreeNode} + */ + generatePaths = () => { + const paths = this.variables + .map((variable) => trimPath([variable.path])) + .map(PATH_TO_OBJECT) + .reduce( + (acc, cur) => { + const { name, absoluteFilePath } = cur; + if (cur.path) { + acc.root.children[cur.path] + ? acc.root.children[cur.path].files.push({ + name, + absoluteFilePath, + }) + : (acc.root.children[cur.path] = { + files: [{ name, absoluteFilePath }], + children: {}, + }); + acc.root.children[cur.path].absolutePath = cur.path; + } else { + acc.root.files + ? acc.root.files.push({ name, absoluteFilePath }) + : (acc.root.files = [{ name, absoluteFilePath }]); + } + return acc; + }, + { root: { files: [], children: {}, absolutePath: '' } } + ); + + COMPACT_EMPTY_DIRS(paths.root.children); + return paths; + }; + + /** + * Search for the named absolutePath within our tree using recursion + * @param {string} name + * @param {Object} root + */ + findPath = (name, root = this.paths.root) => { + if (root.absolutePath === name) { + return root; + } + if (root.children) { + return Object.keys(root.children).reduce((acc, cur) => { + if (!acc) { + return this.findPath(name, root.children[cur]); + } + return acc; + }, null); + } + }; +} diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index f69393fd34..621d15583b 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -51,6 +51,23 @@ function smallCluster(server) { server.create('allocFile', 'dir', { depth: 2 }); server.createList('csi-plugin', 2); server.createList('variable', 3); + [ + 'a/b/c/foo0', + 'a/b/c/bar1', + 'a/b/c/d/e/foo2', + 'a/b/c/d/e/bar3', + 'a/b/c/d/e/f/foo4', + 'a/b/c/d/e/f/g/foo5', + 'a/b/c/x/y/z/foo6', + 'a/b/c/x/y/z/bar7', + 'a/b/c/x/y/z/baz8', + 'w/x/y/foo9', + 'w/x/y/z/foo10', + 'w/x/y/z/bar11', + 'just some arbitrary file', + 'another arbitrary file', + 'another arbitrary file again', + ].forEach((path) => server.create('variable', { path })); // #region evaluations diff --git a/ui/tests/integration/components/variable-paths-test.js b/ui/tests/integration/components/variable-paths-test.js new file mode 100644 index 0000000000..4cc06a82dd --- /dev/null +++ b/ui/tests/integration/components/variable-paths-test.js @@ -0,0 +1,106 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; +import pathTree from 'nomad-ui/utils/path-tree'; + +const PATHSTRINGS = [ + { path: '/foo/bar/baz' }, + { path: '/foo/bar/bay' }, + { path: '/foo/bar/bax' }, + { path: '/a/b' }, + { path: '/a/b/c' }, + { path: '/a/b/canary' }, + { path: '/a/b/canine' }, + { path: '/a/b/chipmunk' }, + { path: '/a/b/c/d' }, + { path: '/a/b/c/dalmation/index' }, + { path: '/a/b/c/doberman/index' }, + { path: '/a/b/c/dachshund/index' }, + { path: '/a/b/c/dachshund/poppy' }, +]; +const tree = new pathTree(PATHSTRINGS); + +module('Integration | Component | variable-paths', function (hooks) { + setupRenderingTest(hooks); + + test('it renders without data', async function (assert) { + assert.expect(2); + + this.set('emptyRoot', { children: {}, files: [] }); + await render(hbs`<VariablePaths @branch={{this.emptyRoot}} />`); + assert.dom('tbody tr').exists({ count: 0 }); + + await componentA11yAudit(this.element, assert); + }); + + test('it renders with data', async function (assert) { + assert.expect(2); + + this.set('tree', tree); + await render(hbs`<VariablePaths @branch={{this.tree.paths.root}} />`); + assert.dom('tbody tr').exists({ count: 2 }, 'There are two rows'); + + await componentA11yAudit(this.element, assert); + }); + + test('it allows for traversal: Folders', async function (assert) { + assert.expect(3); + + this.set('tree', tree); + await render(hbs`<VariablePaths @branch={{this.tree.paths.root}} />`); + assert + .dom('tbody tr:first-child td:first-child a') + .hasAttribute( + 'href', + '/ui/variables/path/foo/bar', + 'Correctly links a folder' + ); + assert + .dom('tbody tr:first-child svg') + .hasAttribute( + 'data-test-icon', + 'folder', + 'Correctly renders the folder icon' + ); + + await componentA11yAudit(this.element, assert); + }); + + test('it allows for traversal: Files', async function (assert) { + assert.expect(5); + + this.set('tree', tree.findPath('foo/bar')); + await render(hbs`<VariablePaths @branch={{this.tree}} />`); + assert + .dom('tbody tr:first-child td:first-child a') + .hasAttribute( + 'href', + '/ui/variables/foo/bar/baz', + 'Correctly links the first file' + ); + assert + .dom('tbody tr:nth-child(2) td:first-child a') + .hasAttribute( + 'href', + '/ui/variables/foo/bar/bay', + 'Correctly links the second file' + ); + assert + .dom('tbody tr:nth-child(3) td:first-child a') + .hasAttribute( + 'href', + '/ui/variables/foo/bar/bax', + 'Correctly links the third file' + ); + assert + .dom('tbody tr:first-child svg') + .hasAttribute( + 'data-test-icon', + 'file-text', + 'Correctly renders the file icon' + ); + await componentA11yAudit(this.element, assert); + }); +}); diff --git a/ui/tests/integration/helpers/trim-path-test.js b/ui/tests/integration/helpers/trim-path-test.js new file mode 100644 index 0000000000..7642bc619c --- /dev/null +++ b/ui/tests/integration/helpers/trim-path-test.js @@ -0,0 +1,29 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | trim-path', function (hooks) { + setupRenderingTest(hooks); + + test('it doesnt mess with internal slashes', async function (assert) { + this.set('inputValue', 'a/b/c/d'); + await render(hbs`{{trim-path this.inputValue}}`); + assert.dom(this.element).hasText('a/b/c/d'); + }); + test('it will remove a prefix slash', async function (assert) { + this.set('inputValue', '/a/b/c/d'); + await render(hbs`{{trim-path this.inputValue}}`); + assert.dom(this.element).hasText('a/b/c/d'); + }); + test('it will remove a suffix slash', async function (assert) { + this.set('inputValue', 'a/b/c/d/'); + await render(hbs`{{trim-path this.inputValue}}`); + assert.dom(this.element).hasText('a/b/c/d'); + }); + test('it will remove both at once', async function (assert) { + this.set('inputValue', '/a/b/c/d/'); + await render(hbs`{{trim-path this.inputValue}}`); + assert.dom(this.element).hasText('a/b/c/d'); + }); +}); diff --git a/ui/tests/unit/utils/path-tree-test.js b/ui/tests/unit/utils/path-tree-test.js new file mode 100644 index 0000000000..565f10a4a5 --- /dev/null +++ b/ui/tests/unit/utils/path-tree-test.js @@ -0,0 +1,98 @@ +import pathTree from 'nomad-ui/utils/path-tree'; +import { module, test } from 'qunit'; + +const PATHSTRINGS = [ + { path: '/foo/bar/baz' }, + { path: '/foo/bar/bay' }, + { path: '/foo/bar/bax' }, + { path: '/a/b' }, + { path: '/a/b/c' }, + { path: '/a/b/canary' }, + { path: '/a/b/canine' }, + { path: '/a/b/chipmunk' }, + { path: '/a/b/c/d' }, + { path: '/a/b/c/dalmation/index' }, + { path: '/a/b/c/doberman/index' }, + { path: '/a/b/c/dachshund/index' }, + { path: '/a/b/c/dachshund/poppy' }, +]; + +module('Unit | Utility | path-tree', function () { + test('it converts path strings to a Variable Path Object ', function (assert) { + const tree = new pathTree(PATHSTRINGS); + assert.ok( + 'root' in tree.paths, + 'Tree has a paths object that begins with a root' + ); + assert.ok('children' in tree.paths.root, 'Root has children'); + assert.equal( + Object.keys(tree.paths.root.children).length, + 2, + 'Root has 2 children (a[...] and foo[...])' + ); + }); + + test('it compacts empty folders correctly', function (assert) { + const tree = new pathTree(PATHSTRINGS); + assert.ok( + 'a' in tree.paths.root.children, + 'root.a is uncompacted since it contains a file (b)' + ); + assert.notOk( + 'foo' in tree.paths.root.children, + 'root.foo does not exist since it contains no files' + ); + assert.ok( + 'foo/bar' in tree.paths.root.children, + 'root.foo/bar is compacted since the only child from foo is bar' + ); + assert.equal( + tree.paths.root.children['foo/bar'].files.length, + 3, + 'A compacted directory contains all terminal files' + ); + }); + + test('it allows for node-based search and traversal', function (assert) { + const tree = new pathTree(PATHSTRINGS); + assert.deepEqual( + tree.paths.root, + tree.findPath(''), + 'Returns tree root on default findPath' + ); + assert.notOk( + tree.findPath('foo'), + 'No path found at the first part of a concatenated folder' + ); // TODO: but maybe we want this to work eventually, so if this test fails because you add mid-tree traversal? Great! + assert.ok( + tree.findPath('foo/bar'), + 'Finds a path at the concatenated folder path' + ); + assert.ok( + tree.findPath('a/b'), + 'Finds a path at the concatenated folder path with multiple subdirectories' + ); + + assert.equal( + Object.keys(tree.findPath('a/b/c').children).length, + 3, + 'Multiple subdirectories are listed at a found compacted path with many child paths' + ); + + assert.equal( + Object.keys(tree.findPath('a/b').files).length, + 4, + 'Multiple files are listed at a found non-terminal compacted path with many secure variables' + ); + assert.equal( + Object.keys(tree.findPath('a/b/c/doberman').files).length, + 1, + 'One file listed at a found compacted path with a single secure variable' + ); + assert.equal( + Object.keys(tree.findPath('a/b/c/dachshund').files).length, + 2, + 'Multiple files listed at a found terminal compacted path with many secure variables' + ); + }); +}); -- GitLab