UI: Ember deprecation - addObject, removeObject (#25952)

* Update add-to-array and remove-from-array helpers

* remove search-select-has-many, moved logic directly into mfa-login-enforcement-form (see #16470)

* Replace add/remove object in MFA files - All MFA tests pass

* Replace in PKI components (pki tests all passing)

* Replace in core addon where applicable

* glimmerize console service -- console tests pass

* more replacements

* update string-list, add comment to vertical-bar-chart

* Refactor CSP Event service

- only used one place (auth-form) so simplified that usage
- glimmerize and refactor so that the tests work

* small updates

* more cleanup

* Fix tests

* Remove objectAt from console-helpers

* Address PR comments

* move commandIndex clearing back

* Remove extra model set
This commit is contained in:
Chelsea Shaw
2024-03-25 13:31:31 -05:00
committed by GitHub
parent 74c350474b
commit 5c18a4e7a4
39 changed files with 239 additions and 193 deletions

View File

@@ -144,7 +144,7 @@ export default ApplicationAdapter.extend({
async _updateAllowedRoles(store, { role, backend, db, type = 'add' }) {
const connection = await store.queryRecord('database/connection', { backend, id: db });
const roles = [...(connection.allowed_roles || [])];
const allowedRoles = type === 'add' ? addToArray([roles, role]) : removeFromArray([roles, role]);
const allowedRoles = type === 'add' ? addToArray(roles, role) : removeFromArray(roles, role);
connection.allowed_roles = allowedRoles;
return connection.save();
},

View File

@@ -7,6 +7,7 @@ import NamedPathAdapter from 'vault/adapters/named-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { service } from '@ember/service';
import AdapterError from '@ember-data/adapter/error';
import { addManyToArray } from 'vault/helpers/add-to-array';
export default class LdapRoleAdapter extends NamedPathAdapter {
@service flashMessages;
@@ -33,7 +34,7 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
async query(store, type, query, recordArray, options) {
const { showPartialError } = options.adapterOptions || {};
const { backend } = query;
const roles = [];
let roles = [];
const errors = [];
for (const roleType of ['static', 'dynamic']) {
@@ -42,7 +43,7 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
return resp.data.keys.map((name) => ({ id: name, name, backend, type: roleType }));
});
roles.addObjects(models);
roles = addManyToArray(roles, models);
} catch (error) {
if (error.httpStatus !== 404) {
errors.push(error);

View File

@@ -6,7 +6,7 @@
import Ember from 'ember';
import { next } from '@ember/runloop';
import { service } from '@ember/service';
import { match, alias, or } from '@ember/object/computed';
import { match, or } from '@ember/object/computed';
import { dasherize } from '@ember/string';
import Component from '@ember/component';
import { computed } from '@ember/object';
@@ -166,9 +166,12 @@ export default Component.extend(DEFAULTS, {
return templateName;
}),
hasCSPError: alias('csp.connectionViolations'),
cspErrorText: `This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`,
cspError: computed('csp.connectionViolations.length', function () {
if (this.csp.connectionViolations.length) {
return `This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`;
}
return '';
}),
allSupportedMethods: computed('methodsToShow', 'hasMethodsWithPath', 'authMethods', function () {
const hasMethodsWithPath = this.hasMethodsWithPath;

View File

@@ -175,7 +175,9 @@ export default class HorizontalBarChart extends Component {
this.isLabel = false;
this.tooltipText = []; // clear stats
this.args.chartLegend.forEach(({ key, label }) => {
this.tooltipText.pushObject(`${formatNumber([data[key]])} ${label}`);
// since we're relying on D3 not ember reactivity,
// pushing directly to this.tooltipText updates the DOM
this.tooltipText.push(`${formatNumber([data[key]])} ${label}`);
});
select(hoveredElement).style('opacity', 1);

View File

@@ -155,7 +155,9 @@ export default class VerticalBarChart extends Component {
this.tooltipStats = []; // clear stats
this.args.chartLegend.forEach(({ key, label }) => {
stackedNumbers.push(data[key]);
this.tooltipStats.pushObject(`${formatNumber([data[key]])} ${label}`);
// since we're relying on D3 not ember reactivity,
// pushing directly to this.tooltipStats updates the DOM
this.tooltipStats.push(`${formatNumber([data[key]])} ${label}`);
});
this.tooltipTotal = `${formatNumber([calculateSum(stackedNumbers)])} ${
data.new_clients ? 'total' : 'new'

View File

@@ -9,6 +9,7 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { removeFromArray } from 'vault/helpers/remove-from-array';
/**
* @module KeymgmtProviderEdit
@@ -94,8 +95,9 @@ export default class KeymgmtProviderEdit extends Component {
@action
async onDeleteKey(model) {
try {
const providerKeys = removeFromArray(this.args.model.keys, model);
await model.destroyRecord();
this.args.model.keys.removeObject(model);
this.args.model.keys = providerKeys;
} catch (error) {
this.flashMessages.danger(error.errors.join('. '));
}

View File

@@ -8,7 +8,8 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import handleHasManySelection from 'core/utils/search-select-has-many';
import { addManyToArray, addToArray } from 'vault/helpers/add-to-array';
import { removeFromArray } from 'vault/helpers/remove-from-array';
/**
* @module MfaLoginEnforcementForm
@@ -64,7 +65,7 @@ export default class MfaLoginEnforcementForm extends Component {
for (const { label, key } of this.targetTypes) {
const targetArray = await this.args.model[key];
const targets = targetArray.map((value) => ({ label, key, value }));
this.targets.addObjects(targets);
this.targets = addManyToArray(this.targets, targets);
}
}
async resetTargetState() {
@@ -102,7 +103,6 @@ export default class MfaLoginEnforcementForm extends Component {
updateModelForKey(key) {
const newValue = this.targets.filter((t) => t.key === key).map((t) => t.value);
// Set the model value directly instead of using Array methods (like .addObject)
this.args.model[key] = newValue;
}
@@ -126,8 +126,18 @@ export default class MfaLoginEnforcementForm extends Component {
@action
async onMethodChange(selectedIds) {
// first make sure the async relationship is loaded
const methods = await this.args.model.mfa_methods;
handleHasManySelection(selectedIds, methods, this.store, 'mfa-method');
// then remove items that are no longer selected
const updatedList = methods.filter((model) => {
return selectedIds.includes(model.id);
});
// then add selected items that don't exist in the list already
const modelIds = updatedList.map((model) => model.id);
const toAdd = selectedIds
.filter((id) => !modelIds.includes(id))
.map((id) => this.store.peekRecord('mfa-method', id));
this.args.model.mfa_methods = addManyToArray(updatedList, toAdd);
}
@action
@@ -150,7 +160,7 @@ export default class MfaLoginEnforcementForm extends Component {
addTarget() {
const { label, key } = this.selectedTarget;
const value = this.selectedTargetValue;
this.targets.addObject({ label, value, key });
this.targets = addToArray(this.targets, { label, value, key });
// recalculate value for appropriate model property
this.updateModelForKey(key);
this.selectedTargetValue = null;
@@ -158,7 +168,7 @@ export default class MfaLoginEnforcementForm extends Component {
}
@action
removeTarget(target) {
this.targets.removeObject(target);
this.targets = removeFromArray(this.targets, target);
// recalculate value for appropriate model property
this.updateModelForKey(target.key);
}

View File

@@ -195,6 +195,7 @@ export default class SecretCreateOrUpdate extends Component {
if (isBlank(item.name)) {
return;
}
// secretData is a KVObject/ArrayProxy so removeObject is fine here
data.removeObject(item);
this.checkRows();
this.handleChange();

View File

@@ -9,6 +9,7 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { capitalize } from '@ember/string';
import { task } from 'ember-concurrency';
import { addToArray } from 'vault/helpers/add-to-array';
export default class MfaMethodCreateController extends Controller {
@service store;
@@ -95,7 +96,8 @@ export default class MfaMethodCreateController extends Controller {
// first save method
yield this.method.save();
if (this.enforcement) {
this.enforcement.mfa_methods.addObject(this.method);
// mfa_methods is type PromiseManyArray so slice in necessary to convert it to an Array
this.enforcement.mfa_methods = addToArray(this.enforcement.mfa_methods.slice(), this.method);
try {
// now save enforcement and catch error separately
yield this.enforcement.save();

View File

@@ -10,7 +10,13 @@ function dedupe(items) {
return items.filter((v, i) => items.indexOf(v) === i);
}
export function addToArray([array, string]) {
export function addManyToArray(array, otherArray) {
assert(`Both values must be an array`, Array.isArray(array) && Array.isArray(otherArray));
const newArray = [...array].concat(otherArray);
return dedupe(newArray);
}
export function addToArray(array, string) {
if (!Array.isArray(array)) {
assert(`Value provided is not an array`, false);
}
@@ -19,4 +25,9 @@ export function addToArray([array, string]) {
return dedupe(newArray);
}
export default buildHelper(addToArray);
export default buildHelper(function ([array, string]) {
if (Array.isArray(string)) {
return addManyToArray(array, string);
}
return addToArray(array, string);
});

View File

@@ -10,10 +10,14 @@ function dedupe(items) {
return items.filter((v, i) => items.indexOf(v) === i);
}
export function removeFromArray([array, string]) {
if (!Array.isArray(array)) {
assert(`Value provided is not an array`, false);
}
export function removeManyFromArray(array, toRemove) {
assert(`Both values must be an array`, Array.isArray(array) && Array.isArray(toRemove));
const a = [...(array || [])];
return a.filter((v) => !toRemove.includes(v));
}
export function removeFromArray(array, string) {
assert(`Value provided is not an array`, Array.isArray(array));
const newArray = [...array];
const idx = newArray.indexOf(string);
if (idx >= 0) {
@@ -22,4 +26,9 @@ export function removeFromArray([array, string]) {
return dedupe(newArray);
}
export default buildHelper(removeFromArray);
export default buildHelper(function ([array, string]) {
if (Array.isArray(string)) {
return removeManyFromArray(array, string);
}
return removeFromArray(array, string);
});

View File

@@ -241,7 +241,8 @@ export function shiftCommandIndex(keyCode: number, history: CommandLog[], index:
}
if (newInputValue !== '') {
newInputValue = history.objectAt(index)?.content;
const objAt = history[index];
newInputValue = objAt?.content;
}
return [index, newInputValue];

View File

@@ -8,6 +8,7 @@ import { computed } from '@ember/object';
import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import apiPath from 'vault/utils/api-path';
import lazyCapabilities from 'vault/macros/lazy-capabilities';
import { removeManyFromArray } from 'vault/helpers/remove-from-array';
export const COMPUTEDS = {
operationFields: computed('newFields', function () {
@@ -15,7 +16,7 @@ export const COMPUTEDS = {
}),
operationFieldsWithoutSpecial: computed('operationFields', function () {
return this.operationFields.slice().removeObjects(['operationAll', 'operationNone']);
return removeManyFromArray(this.operationFields, ['operationAll', 'operationNone']);
}),
tlsFields: computed(function () {
@@ -25,12 +26,12 @@ export const COMPUTEDS = {
// For rendering on the create/edit pages
defaultFields: computed('newFields', 'operationFields', 'tlsFields', function () {
const excludeFields = ['role'].concat(this.operationFields, this.tlsFields);
return this.newFields.slice().removeObjects(excludeFields);
return removeManyFromArray(this.newFields, excludeFields);
}),
// For adapter/serializer
nonOperationFields: computed('newFields', 'operationFields', function () {
return this.newFields.slice().removeObjects(this.operationFields);
return removeManyFromArray(this.newFields, this.operationFields);
}),
};
@@ -64,9 +65,11 @@ export default Model.extend(COMPUTEDS, {
const attributes = ['operationAddAttribute', 'operationGetAttributes'];
const server = ['operationDiscoverVersions'];
const others = this.operationFieldsWithoutSpecial
.slice()
.removeObjects(objects.concat(attributes, server));
const others = removeManyFromArray(this.operationFieldsWithoutSpecial, [
...objects,
...attributes,
...server,
]);
const groups = [
{ 'Managed Cryptographic Objects': objects },
{ 'Object Attributes': attributes },

View File

@@ -10,6 +10,7 @@ import { methods } from 'vault/helpers/mountable-auth-methods';
import { withModelValidations } from 'vault/decorators/model-validations';
import { isPresent } from '@ember/utils';
import { service } from '@ember/service';
import { addManyToArray, addToArray } from 'vault/helpers/add-to-array';
const validations = {
name: [{ type: 'presence', message: 'Name is required' }],
@@ -52,7 +53,7 @@ export default class MfaLoginEnforcementModel extends Model {
async prepareTargets() {
let authMethods;
const targets = [];
let targets = [];
if (this.auth_method_accessors.length || this.auth_method_types.length) {
// fetch all auth methods and lookup by accessor to get mount path and type
@@ -68,7 +69,8 @@ export default class MfaLoginEnforcementModel extends Model {
const selectedAuthMethods = authMethods.filter((model) => {
return this.auth_method_accessors.includes(model.accessor);
});
targets.addObjects(
targets = addManyToArray(
targets,
selectedAuthMethods.map((method) => ({
icon: this.iconForMount(method.type),
link: 'vault.cluster.access.method',
@@ -82,7 +84,7 @@ export default class MfaLoginEnforcementModel extends Model {
this.auth_method_types.forEach((type) => {
const icon = this.iconForMount(type);
const mountCount = authMethods.filterBy('type', type).length;
targets.addObject({
targets = addToArray(targets, {
key: 'auth_method_types',
icon,
title: type,
@@ -92,7 +94,7 @@ export default class MfaLoginEnforcementModel extends Model {
for (const key of ['identity_entities', 'identity_groups']) {
(await this[key]).forEach((model) => {
targets.addObject({
targets = addToArray(targets, {
key,
icon: 'user',
link: 'vault.cluster.access.identity.show',

View File

@@ -50,7 +50,7 @@ export default class SyncDestinationSerializer extends ApplicationSerializer {
const type = key.replace(/\/$/, '');
const id = `${type}/${name}`;
// create object with destination's id and attributes, add to payload
transformedPayload.pushObject({ id, name, type });
transformedPayload.push({ id, name, type });
});
}
return transformedPayload;

View File

@@ -17,6 +17,7 @@ import { resolve, reject } from 'rsvp';
import getStorage from 'vault/lib/token-storage';
import ENV from 'vault/config/environment';
import { allSupportedAuthBackends } from 'vault/helpers/supported-auth-backends';
import { addToArray } from 'vault/helpers/add-to-array';
const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-';
@@ -250,7 +251,6 @@ export default Service.extend({
persistAuthData() {
const [firstArg, resp] = arguments;
const tokens = this.tokens;
const currentNamespace = this.namespaceService.path || '';
// dropdown vs tab format
const mountPath = firstArg?.data?.path || firstArg?.selectedAuth;
@@ -303,8 +303,7 @@ export default Service.extend({
if (!data.displayName) {
data.displayName = (this.getTokenData(tokenName) || {}).displayName;
}
tokens.addObject(tokenName);
this.set('tokens', tokens);
this.set('tokens', addToArray(this.tokens, tokenName));
this.set('allowExpiration', false);
this.setTokenData(tokenName, data);
return resolve({

View File

@@ -6,12 +6,12 @@
// Low level service that allows users to input paths to make requests to vault
// this service provides the UI synecdote to the cli commands read, write, delete, and list
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { computed } from '@ember/object';
import { shiftCommandIndex } from 'vault/lib/console-helpers';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { sanitizePath, ensureTrailingSlash } from 'core/utils/sanitize-path';
import { tracked } from '@glimmer/tracking';
import { addManyToArray } from 'vault/helpers/add-to-array';
const VERBS = {
read: 'GET',
@@ -20,51 +20,51 @@ const VERBS = {
delete: 'DELETE',
};
export default Service.extend({
isOpen: false,
export default class ConsoleService extends Service {
@tracked isOpen = false;
@tracked commandIndex = null;
@tracked log = [];
adapter() {
return getOwner(this).lookup('adapter:console');
},
get commandHistory() {
return this.log.filter((log) => log.type === 'command');
},
log: computed(function () {
return [];
}),
commandIndex: null,
}
// Not a getter so it can be stubbed in tests
adapter() {
return getOwner(this).lookup('adapter:console');
}
shiftCommandIndex(keyCode, setCommandFn = () => {}) {
const [newIndex, newCommand] = shiftCommandIndex(keyCode, this.commandHistory, this.commandIndex);
if (newCommand !== undefined && newIndex !== undefined) {
this.set('commandIndex', newIndex);
this.commandIndex = newIndex;
setCommandFn(newCommand);
}
},
}
clearLog(clearAll = false) {
const log = this.log;
let history;
if (!clearAll) {
history = this.commandHistory.slice();
history.setEach('hidden', true);
}
log.clear();
this.log = [];
if (history) {
log.addObjects(history);
this.log = addManyToArray(this.log, history);
}
},
}
logAndOutput(command, logContent) {
const log = this.log;
const log = this.log.slice();
if (command) {
log.pushObject({ type: 'command', content: command });
this.set('commandIndex', null);
log.push({ type: 'command', content: command });
this.commandIndex = null;
}
if (logContent) {
log.pushObject(logContent);
log.push(logContent);
}
},
this.log = log;
}
ajax(operation, path, options = {}) {
const verb = VERBS[operation];
@@ -75,7 +75,7 @@ export default Service.extend({
data,
wrapTTL,
});
},
}
kvGet(path, data, flags = {}) {
const { wrapTTL, metadata } = flags;
@@ -84,21 +84,21 @@ export default Service.extend({
const [backend, secretPath] = path.split(/\/(.+)?/);
const kvPath = `${backend}/${pathSegment}/${secretPath}`;
return this.ajax('read', sanitizePath(kvPath), { wrapTTL });
},
}
read(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
return this.ajax('read', sanitizePath(path), { wrapTTL });
},
}
write(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
return this.ajax('write', sanitizePath(path), { data, wrapTTL });
},
}
delete(path) {
return this.ajax('delete', sanitizePath(path));
},
}
list(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
@@ -109,5 +109,5 @@ export default Service.extend({
},
wrapTTL,
});
},
});
}
}

View File

@@ -3,34 +3,35 @@
* SPDX-License-Identifier: BUSL-1.1
*/
/*eslint-disable no-constant-condition*/
import { computed } from '@ember/object';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task, waitForEvent } from 'ember-concurrency';
import { addToArray } from 'vault/helpers/add-to-array';
export default Service.extend({
events: computed(function () {
return [];
}),
connectionViolations: computed('events.@each.violatedDirective', function () {
return this.events.some((e) => e.violatedDirective.startsWith('connect-src'));
}),
export default class CspEventService extends Service {
@tracked connectionViolations = [];
attach() {
this.monitor.perform();
},
}
remove() {
this.monitor.cancelAll();
},
}
monitor: task(function* () {
this.events.clear();
handleEvent(event) {
if (event.violatedDirective.startsWith('connect-src')) {
this.connectionViolations = addToArray(this.connectionViolations, event);
}
}
@task
*monitor() {
this.connectionViolations = [];
while (true) {
const event = yield waitForEvent(window.document, 'securitypolicyviolation');
this.events.addObject(event);
this.handleEvent(event);
}
}),
});
}
}

View File

@@ -11,6 +11,7 @@ import { capitalize } from '@ember/string';
import getStorage from 'vault/lib/token-storage';
import { STORAGE_KEYS, DEFAULTS, MACHINES } from 'vault/helpers/wizard-constants';
import { addToArray } from 'vault/helpers/add-to-array';
const {
TUTORIAL_STATE,
COMPONENT_STATE,
@@ -101,7 +102,7 @@ export default Service.extend(DEFAULTS, {
} else {
if (this.featureMachineHistory) {
if (!this.featureMachineHistory.includes(state)) {
const newHistory = this.featureMachineHistory.addObject(state);
const newHistory = addToArray(this.featureMachineHistory, state);
this.set('featureMachineHistory', newHistory);
} else {
//we're repeating steps
@@ -293,7 +294,7 @@ export default Service.extend(DEFAULTS, {
return;
}
this.startFeature();
const nextFeature = this.featureList.length > 1 ? capitalize(this.featureList.objectAt(1)) : 'Finish';
const nextFeature = this.featureList.length > 1 ? capitalize(this.featureList[1]) : 'Finish';
this.set('nextFeature', nextFeature);
let next;
if (this.currentMachine === 'secrets' && this.featureState === 'display') {
@@ -311,9 +312,9 @@ export default Service.extend(DEFAULTS, {
},
startFeature() {
const FeatureMachineConfig = MACHINES[this.featureList.objectAt(0)];
const FeatureMachineConfig = MACHINES[this.featureList[0]];
FeatureMachine = Machine(FeatureMachineConfig);
this.set('currentMachine', this.featureList.objectAt(0));
this.set('currentMachine', this.featureList[0]);
if (this.storageHasKey(FEATURE_STATE)) {
this.saveState('featureState', this.getExtState(FEATURE_STATE));
} else {
@@ -337,7 +338,7 @@ export default Service.extend(DEFAULTS, {
completed.push(done);
this.saveExtState(COMPLETED_FEATURES, completed);
} else {
this.saveExtState(COMPLETED_FEATURES, this.getExtState(COMPLETED_FEATURES).addObject(done));
this.saveExtState(COMPLETED_FEATURES, addToArray(this.getExtState(COMPLETED_FEATURES), done));
}
this.saveExtState(FEATURE_LIST, features.length ? features : null);

View File

@@ -50,7 +50,7 @@
</nav>
{{/if}}
<div class="box is-marginless is-shadowless">
<MessageError @errorMessage={{if (and this.cluster.standby this.hasCSPError) this.cspErrorText this.error}} />
<MessageError @errorMessage={{if (and this.cluster.standby this.cspError) this.cspError this.error}} />
{{#if this.selectedAuthBackend.path}}
<div class="has-bottom-margin-s">
<p class="is-label">{{this.selectedAuthBackend.path}}</p>

View File

@@ -10,6 +10,8 @@ import { capitalize } from 'vault/helpers/capitalize';
import { humanize } from 'vault/helpers/humanize';
import { dasherize } from 'vault/helpers/dasherize';
import { assert } from '@ember/debug';
import { addToArray } from 'vault/helpers/add-to-array';
import { removeFromArray } from 'vault/helpers/remove-from-array';
/**
* @module FormField
@@ -193,9 +195,12 @@ export default class FormFieldComponent extends Component {
@action
handleChecklist(event) {
const valueArray = this.args.model[this.valuePath];
const method = event.target.checked ? 'addObject' : 'removeObject';
valueArray[method](event.target.value);
this.setAndBroadcast(valueArray);
let updatedValue = this.args.model[this.valuePath];
if (event.target.checked) {
updatedValue = addToArray(updatedValue, event.target.value);
} else {
updatedValue = removeFromArray(updatedValue, event.target.value);
}
this.setAndBroadcast(updatedValue);
}
}

View File

@@ -40,6 +40,7 @@ import KVObject from 'vault/lib/kv-object';
*/
export default class KvObjectEditor extends Component {
// kvData is type ArrayProxy, so addObject etc are fine here
@tracked kvData;
get placeholders() {

View File

@@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
import { removeFromArray } from 'vault/helpers/remove-from-array';
/**
* @module ObjectListInput
@@ -65,7 +66,7 @@ export default class ObjectListInput extends Component {
@action
handleInput(idx, { target }) {
const inputObj = this.inputList.objectAt(idx);
const inputObj = this.inputList[idx];
inputObj[target.name] = target.value;
this.handleChange();
}
@@ -79,8 +80,8 @@ export default class ObjectListInput extends Component {
@action
removeRow(idx) {
const row = this.inputList.objectAt(idx);
this.inputList.removeObject(row);
const row = this.inputList[idx];
this.inputList = removeFromArray(this.inputList, row);
this.handleChange();
}

View File

@@ -9,6 +9,8 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-utils';
import { removeFromArray } from 'vault/helpers/remove-from-array';
import { addToArray } from 'vault/helpers/add-to-array';
/**
* @module SearchSelectWithModal
@@ -31,7 +33,7 @@ import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-ut
* />
*
// * component functionality
* @param {function} onChange - The onchange action for this form field. ** SEE UTIL ** search-select-has-many.js if selecting models from a hasMany relationship
* @param {function} onChange - The onchange action for this form field. ** SEE EXAMPLE ** mfa-login-enforcement-form.js (onMethodChange) for example when selecting models from a hasMany relationship
* @param {array} [inputValue] - Array of strings corresponding to the input's initial value, e.g. an array of model ids that on edit will appear as selected items below the input
* @param {boolean} [shouldRenderName=false] - By default an item's id renders in the dropdown, `true` displays the name with its id in smaller text beside it *NOTE: the boolean flips automatically with 'identity' models
* @param {array} [excludeOptions] - array of strings containing model ids to filter from the dropdown (ex: ['allow_all'])
@@ -81,7 +83,7 @@ export default class SearchSelectWithModal extends Component {
return inputValues.map((option) => {
const matchingOption = this.dropdownOptions.find((opt) => opt.id === option);
// remove any matches from dropdown list
this.dropdownOptions.removeObject(matchingOption);
this.dropdownOptions = removeFromArray(this.dropdownOptions, matchingOption);
return {
id: option,
name: matchingOption ? matchingOption.name : option,
@@ -168,8 +170,8 @@ export default class SearchSelectWithModal extends Component {
// -----
@action
discardSelection(selected) {
this.selectedOptions.removeObject(selected);
this.dropdownOptions.pushObject(selected);
this.selectedOptions = removeFromArray(this.selectedOptions, selected);
this.dropdownOptions = addToArray(this.dropdownOptions, selected);
this.handleChange();
}
@@ -196,8 +198,8 @@ export default class SearchSelectWithModal extends Component {
this.showModal = true;
} else {
// user has selected an existing item, handleChange immediately
this.selectedOptions.pushObject(selection);
this.dropdownOptions.removeObject(selection);
this.selectedOptions = addToArray(this.selectedOptions, selection);
this.dropdownOptions = removeFromArray(this.dropdownOptions, selection);
this.handleChange();
}
}
@@ -209,7 +211,7 @@ export default class SearchSelectWithModal extends Component {
this.showModal = false;
if (model && model.currentState.isSaved) {
const { name } = model;
this.selectedOptions.pushObject({ name, id: name });
this.selectedOptions = addToArray(this.selectedOptions, { name, id: name });
this.handleChange();
}
this.nameInput = null;

View File

@@ -10,6 +10,8 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { resolve } from 'rsvp';
import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-utils';
import { removeFromArray } from 'vault/helpers/remove-from-array';
import { addToArray } from 'vault/helpers/add-to-array';
/**
* @module SearchSelect
* The `SearchSelect` is an implementation of the [ember-power-select](https://github.com/cibernox/ember-power-select) used for form elements where options come dynamically from the API.
@@ -28,7 +30,7 @@ import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-ut
* />
*
// * component functionality
* @param {function} onChange - The onchange action for this form field. ** SEE UTIL ** search-select-has-many.js if selecting models from a hasMany relationship
* @param {function} onChange - The onchange action for this form field. ** SEE EXAMPLE ** mfa-login-enforcement-form.js (onMethodChange) for example when selecting models from a hasMany relationship
* @param {array} [inputValue] - Array of strings corresponding to the input's initial value, e.g. an array of model ids that on edit will appear as selected items below the input
* @param {boolean} [disallowNewItems=false] - Controls whether or not the user can add a new item if none found
* @param {boolean} [shouldRenderName=false] - By default an item's id renders in the dropdown, `true` displays the name with its id in smaller text beside it *NOTE: the boolean flips automatically with 'identity' models or if this.idKey !== 'id'
@@ -118,7 +120,7 @@ export default class SearchSelect extends Component {
: false;
// remove any matches from dropdown list
this.dropdownOptions.removeObject(matchingOption);
this.dropdownOptions = removeFromArray(this.dropdownOptions, matchingOption);
return {
id: option,
name: matchingOption ? matchingOption[this.nameKey] : option,
@@ -263,9 +265,9 @@ export default class SearchSelect extends Component {
@action
discardSelection(selected) {
this.selectedOptions.removeObject(selected);
this.selectedOptions = removeFromArray(this.selectedOptions, selected);
if (!selected.new) {
this.dropdownOptions.pushObject(selected);
this.dropdownOptions = addToArray(this.dropdownOptions, selected);
}
this.handleChange();
}
@@ -291,10 +293,10 @@ export default class SearchSelect extends Component {
selectOrCreate(selection) {
if (selection && selection.__isSuggestion__) {
const name = selection.__value__;
this.selectedOptions.pushObject({ name, id: name, new: true });
this.selectedOptions = addToArray(this.selectedOptions, { name, id: name, new: true });
} else {
this.selectedOptions.pushObject(selection);
this.dropdownOptions.removeObject(selection);
this.selectedOptions = addToArray(this.selectedOptions, selection);
this.dropdownOptions = removeFromArray(this.dropdownOptions, selection);
}
this.handleChange();
}

View File

@@ -10,6 +10,8 @@ import { action } from '@ember/object';
import { set } from '@ember/object';
import { next } from '@ember/runloop';
import { tracked } from '@glimmer/tracking';
import { addToArray } from 'vault/helpers/add-to-array';
import { removeFromArray } from 'vault/helpers/remove-from-array';
/**
* @module StringList
@@ -33,6 +35,7 @@ export default class StringList extends Component {
constructor() {
super(...arguments);
// inputList is type ArrayProxy, so addObject etc are fine here
this.inputList = ArrayProxy.create({
// trim the `value` when accessing objects
content: [],
@@ -90,9 +93,11 @@ export default class StringList extends Component {
@action
inputChanged(idx, event) {
if (event.target.value.includes(',') && !this.indicesWithComma.includes(idx)) {
this.indicesWithComma.pushObject(idx);
this.indicesWithComma = addToArray(this.indicesWithComma, idx);
}
if (!event.target.value.includes(',')) {
this.indicesWithComma = removeFromArray(this.indicesWithComma, idx);
}
if (!event.target.value.includes(',')) this.indicesWithComma.removeObject(idx);
const inputObj = this.inputList.objectAt(idx);
set(inputObj, 'value', event.target.value);
@@ -109,8 +114,8 @@ export default class StringList extends Component {
@action
removeInput(idx) {
const inputs = this.inputList;
inputs.removeObject(inputs.objectAt(idx));
const itemToRemove = this.inputList.objectAt(idx);
this.inputList.removeObject(itemToRemove);
this.args.onChange(this.toVal());
}
}

View File

@@ -1,38 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
/**
* Util to add/remove models in a hasMany relationship via the search-select component
*
* @example of using the util within an action in a component
*```js
* @action
* async onSearchSelectChange(selectedIds) {
* const methods = await this.args.model.mfa_methods;
* handleHasManySelection(selectedIds, methods, this.store, 'mfa-method');
* }
*
* @param selectedIds array of selected options from search-select component
* @param modelCollection array-like, list of models from the hasMany relationship
* @param store the store so we can call peekRecord()
* @param modelRecord string passed to peekRecord
*/
export default function handleHasManySelection(selectedIds, modelCollection, store, modelRecord) {
// first check for existing models that have been removed from selection
modelCollection.forEach((model) => {
if (!selectedIds.includes(model.id)) {
modelCollection.removeObject(model);
}
});
// now check for selected items that don't exist and add them to the model
const modelIds = modelCollection.map((model) => model.id);
selectedIds.forEach((id) => {
if (!modelIds.includes(id)) {
const model = store.peekRecord(modelRecord, id);
modelCollection.addObject(model);
}
});
}

View File

@@ -32,7 +32,6 @@ export default class RolesPageComponent extends Component {
try {
const message = `Successfully deleted role ${model.name}`;
await model.destroyRecord();
this.args.roles.removeObject(model);
this.flashMessages.success(message);
} catch (error) {
const message = errorMessage(error, 'Error deleting role. Please try again or contact support');

View File

@@ -18,6 +18,7 @@ import type PkiConfigClusterModel from 'vault/models/pki/config/cluster';
import type PkiConfigCrlModel from 'vault/models/pki/config/crl';
import type PkiConfigUrlsModel from 'vault/models/pki/config/urls';
import type { FormField, TtlEvent } from 'vault/app-types';
import { addToArray } from 'vault/helpers/add-to-array';
interface Args {
acme: PkiConfigAcmeModel;
@@ -76,7 +77,7 @@ export default class PkiConfigurationEditComponent extends Component<Args> {
message: errorMessage(error),
};
this.flashMessages.danger(`Error updating config/${modelName}`, { sticky: true });
this.errors.pushObject(errorObject);
this.errors = addToArray(this.errors, errorObject);
}
}

View File

@@ -13,6 +13,8 @@ import errorMessage from 'vault/utils/error-message';
import type RouterService from '@ember/routing/router-service';
import type FlashMessageService from 'vault/services/flash-messages';
import type PkiIssuerModel from 'vault/models/pki/issuer';
import { removeFromArray } from 'vault/helpers/remove-from-array';
import { addToArray } from 'vault/helpers/add-to-array';
interface Args {
model: PkiIssuerModel;
@@ -36,8 +38,11 @@ export default class PkiIssuerEditComponent extends Component<Args> {
@action
setUsage(value: string) {
const method = this.usageValues.includes(value) ? 'removeObject' : 'addObject';
this.usageValues[method](value);
if (this.usageValues.includes(value)) {
this.usageValues = removeFromArray(this.usageValues, value);
} else {
this.usageValues = addToArray(this.usageValues, value);
}
this.args.model.usage = this.usageValues.join(',');
}

View File

@@ -11,6 +11,7 @@ import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import { waitFor } from '@ember/test-waiters';
import { parseCertificate } from 'vault/utils/parse-pki-cert';
import { addToArray } from 'vault/helpers/add-to-array';
/**
* @module PkiIssuerCrossSign
* PkiIssuerCrossSign components render from a parent issuer's details page to cross-sign an intermediate issuer (from a different mount).
@@ -92,7 +93,7 @@ export default class PkiIssuerCrossSign extends Component {
// for cross-signing error handling we want to record the list of issuers before the process starts
this.intermediateIssuers[intermediateMount] = issuers;
this.validationErrors.addObject({
this.validationErrors = addToArray(this.validationErrors, {
newCrossSignedIssuer: this.nameValidation(newCrossSignedIssuer, issuers),
});
}
@@ -109,9 +110,9 @@ export default class PkiIssuerCrossSign extends Component {
intermediateIssuer,
newCrossSignedIssuer
);
this.signedIssuers.addObject({ ...data, hasError: false });
this.signedIssuers = addToArray(this.signedIssuers, { ...data, hasError: false });
} catch (error) {
this.signedIssuers.addObject({
this.signedIssuers = addToArray(this.signedIssuers, {
...this.formData[row],
hasError: errorMessage(error),
hasUnsupportedParams: error.cause ? error.cause.map((e) => e.message).join(', ') : null,

View File

@@ -38,7 +38,7 @@ export default function (server) {
let records = [];
if (isMethod) {
methods.forEach((method) => {
records.addObjects(schema.db[dbKeyFromType(method)].where({}));
records = [...records, ...schema.db[dbKeyFromType(method)].where({})];
});
} else {
records = schema.db.mfaLoginEnforcements.where({});

View File

@@ -21,7 +21,7 @@ module('Acceptance | mfa-method', function (hooks) {
this.store = this.owner.lookup('service:store');
this.getMethods = () =>
['Totp', 'Duo', 'Okta', 'Pingid'].reduce((methods, type) => {
methods.addObjects(this.server.db[`mfa${type}Methods`].where({}));
methods = [...methods, ...this.server.db[`mfa${type}Methods`].where({})];
return methods;
}, []);
return authPage.login();

View File

@@ -51,9 +51,9 @@ module('Integration | Component | auth form', function (hooks) {
assert.expect(2);
this.set('cluster', EmberObject.create({ standby: true }));
this.set('selectedAuth', 'token');
await render(hbs`{{auth-form cluster=this.cluster selectedAuth=this.selectedAuth}}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
assert.false(component.errorMessagePresent, false);
this.owner.lookup('service:csp-event').events.addObject({ violatedDirective: 'connect-src' });
this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' });
await settled();
assert.strictEqual(component.errorText, CSP_ERR_TEXT);
});
@@ -75,7 +75,7 @@ module('Integration | Component | auth form', function (hooks) {
this.set('cluster', EmberObject.create({}));
this.set('selectedAuth', 'token');
await render(hbs`{{auth-form cluster=this.cluster selectedAuth=this.selectedAuth}}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
return component.login().then(() => {
assert.strictEqual(component.errorText, 'Error Authentication failed: Not allowed');
server.shutdown();
@@ -86,17 +86,20 @@ module('Integration | Component | auth form', function (hooks) {
assert.expect(1);
const server = new Pretender(function () {
this.get('/v1/auth/**', () => {
return [400, { 'Content-Type': 'application/json' }];
return [400, { 'Content-Type': 'application/json' }, JSON.stringify({ errors: ['API Error here'] })];
});
this.get('/v1/sys/internal/ui/mounts', this.passthrough);
});
this.set('cluster', EmberObject.create({}));
this.set('selectedAuth', 'token');
await render(hbs`{{auth-form cluster=this.cluster selectedAuth=this.selectedAuth}}`);
// returns null because test does not return details of failed network request. On the app it will return the details of the error instead of null.
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
return component.login().then(() => {
assert.strictEqual(component.errorText, 'Error Authentication failed: null');
assert.strictEqual(
component.errorText,
'Error Authentication failed: API Error here',
'shows the error from the API'
);
server.shutdown();
});
});
@@ -134,7 +137,7 @@ module('Integration | Component | auth form', function (hooks) {
});
this.set('cluster', EmberObject.create({}));
await render(hbs`{{auth-form cluster=this.cluster }}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
assert.strictEqual(component.tabs.length, 2, 'renders a tab for userpass and Other');
assert.strictEqual(component.tabs.objectAt(0).name, 'foo', 'uses the path in the label');
@@ -155,7 +158,7 @@ module('Integration | Component | auth form', function (hooks) {
});
});
this.set('cluster', EmberObject.create({}));
await render(hbs`{{auth-form cluster=this.cluster }}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
assert.strictEqual(
component.descriptionText,
@@ -183,7 +186,7 @@ module('Integration | Component | auth form', function (hooks) {
this.set('cluster', EmberObject.create({}));
this.set('selectedAuth', 'foo/');
await render(hbs`{{auth-form cluster=this.cluster selectedAuth=this.selectedAuth}}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
await component.login();
await settled();
@@ -267,7 +270,7 @@ module('Integration | Component | auth form', function (hooks) {
});
this.set('wrappedToken', '54321');
await render(hbs`{{auth-form cluster=this.cluster wrappedToken=this.wrappedToken}}`);
await render(hbs`<AuthForm @cluster={{this.cluster}} @wrappedToken={{this.wrappedToken}} />`);
later(() => cancelTimers(), 50);
await settled();

View File

@@ -147,7 +147,7 @@ module('Integration | Component | ldap | Page::Library::CreateAndEdit', function
await this.renderComponent();
await click('[data-test-string-list-button="delete"]');
await click('[data-test-string-list-row="0"] [data-test-string-list-button="delete"]');
await click('[data-test-input="disable_check_in_enforcement"] input#Disabled');
await click('[data-test-save]');

View File

@@ -153,8 +153,8 @@ module('Integration | Component | mfa-login-enforcement-form', function (hooks)
test('it should populate fields with model data', async function (assert) {
this.model.name = 'foo';
const [method] = await this.store.query('mfa-method', {});
this.model.mfa_methods.addObject(method);
this.model.auth_method_accessors.addObject('auth_userpass_1234');
this.model.mfa_methods = [method];
this.model.auth_method_accessors = ['auth_userpass_1234'];
await render(hbs`
<Mfa::MfaLoginEnforcementForm
@@ -207,12 +207,12 @@ module('Integration | Component | mfa-login-enforcement-form', function (hooks)
keys: ['1234'],
},
}));
this.model.auth_method_accessors.addObject('auth_userpass_1234');
this.model.auth_method_types.addObject('userpass');
this.model.auth_method_accessors = ['auth_userpass_1234'];
this.model.auth_method_types = ['userpass'];
const [entity] = await this.store.query('identity/entity', {});
this.model.identity_entities.addObject(entity);
this.model.identity_entities = [entity];
const [group] = await this.store.query('identity/group', {});
this.model.identity_groups.addObject(group);
this.model.identity_groups = [group];
await render(hbs`
<Mfa::MfaLoginEnforcementForm

View File

@@ -159,6 +159,7 @@ module('Integration | Component | path filter config list', function (hooks) {
await clickTrigger();
await searchSelect.deleteButtons.objectAt(1).click();
await clickTrigger();
await typeInSearch('ns1');
assert.dom('.ember-power-select-group').hasText('Namespaces ns1', 'puts ns back within group');
await clickTrigger();
});

View File

@@ -12,7 +12,7 @@ module('Integration | Helper | add-to-array', function (hooks) {
test('it correctly adds a value to an array without mutating the original', function (assert) {
const ARRAY = ['horse', 'cow', 'chicken'];
const result = addToArray([ARRAY, 'pig']);
const result = addToArray(ARRAY, 'pig');
assert.deepEqual(result, [...ARRAY, 'pig'], 'Result has additional item');
assert.deepEqual(ARRAY, ['horse', 'cow', 'chicken'], 'original array is not mutated');
});
@@ -20,7 +20,7 @@ module('Integration | Helper | add-to-array', function (hooks) {
test('it fails if the first value is not an array', function (assert) {
let result;
try {
result = addToArray(['not-array', 'string']);
result = addToArray('not-array', 'string');
} catch (e) {
result = e.message;
}
@@ -29,13 +29,13 @@ module('Integration | Helper | add-to-array', function (hooks) {
test('it works with non-string arrays', function (assert) {
const ARRAY = ['five', 6, '7'];
const result = addToArray([ARRAY, 10]);
const result = addToArray(ARRAY, 10);
assert.deepEqual(result, ['five', 6, '7', 10], 'added number value');
});
test('it de-dupes the result', function (assert) {
const ARRAY = ['horse', 'cow', 'chicken'];
const result = addToArray([ARRAY, 'horse']);
const result = addToArray(ARRAY, 'horse');
assert.deepEqual(result, ['horse', 'cow', 'chicken']);
});
});

View File

@@ -5,28 +5,28 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { removeFromArray } from '../../../helpers/remove-from-array';
import { removeManyFromArray, removeFromArray } from 'vault/helpers/remove-from-array';
module('Integration | Helper | remove-from-array', function (hooks) {
setupRenderingTest(hooks);
test('it correctly removes a value from an array without mutating the original', function (assert) {
const ARRAY = ['horse', 'cow', 'chicken'];
const result = removeFromArray([ARRAY, 'horse']);
const result = removeFromArray(ARRAY, 'horse');
assert.deepEqual(result, ['cow', 'chicken'], 'Result does not have removed item');
assert.deepEqual(ARRAY, ['horse', 'cow', 'chicken'], 'original array is not mutated');
});
test('it returns the same value if the item is not found', function (assert) {
const ARRAY = ['horse', 'cow', 'chicken'];
const result = removeFromArray([ARRAY, 'pig']);
const result = removeFromArray(ARRAY, 'pig');
assert.deepEqual(result, ARRAY, 'Results are the same as original array');
});
test('it fails if the first value is not an array', function (assert) {
let result;
try {
result = removeFromArray(['not-array', 'string']);
result = removeFromArray('not-array', 'string');
} catch (e) {
result = e.message;
}
@@ -35,15 +35,23 @@ module('Integration | Helper | remove-from-array', function (hooks) {
test('it works with non-string arrays', function (assert) {
const ARRAY = ['five', 6, '7'];
const result1 = removeFromArray([ARRAY, 6]);
const result2 = removeFromArray([ARRAY, 7]);
const result1 = removeFromArray(ARRAY, 6);
const result2 = removeFromArray(ARRAY, 7);
assert.deepEqual(result1, ['five', '7'], 'removed number value');
assert.deepEqual(result2, ARRAY, 'did not match on different types');
});
test('it de-dupes the result', function (assert) {
const ARRAY = ['horse', 'cow', 'chicken', 'cow'];
const result = removeFromArray([ARRAY, 'horse']);
const result = removeFromArray(ARRAY, 'horse');
assert.deepEqual(result, ['cow', 'chicken']);
});
test('it works with two arrays', function (assert) {
const ARRAY = ['five', 6, '7'];
const result1 = removeManyFromArray(ARRAY, [6, '7']);
const result2 = removeManyFromArray(ARRAY, ['foo', 'five']);
assert.deepEqual(result1, ['five'], 'removed multiple values');
assert.deepEqual(result2, [6, '7'], 'did nothing with values that were not in the array');
});
});