Clients config updates for census reporting (#20125)

* updates clients config view for census reporting

* adds changelog entry

* fixes issue with modal staying open and error not showing on clients config save failure

* adds min retention months to clients config model and form validation
This commit is contained in:
Jordan Reimer 2023-04-13 15:57:12 -06:00 committed by GitHub
parent 1b4ff1b1b4
commit c36ab935c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 216 additions and 198 deletions

3
changelog/20125.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: updates clients configuration edit form state based on census reporting configuration
```

View File

@ -23,9 +23,11 @@ import { task } from 'ember-concurrency';
export default class ConfigComponent extends Component { export default class ConfigComponent extends Component {
@service router; @service router;
@tracked mode = 'show'; @tracked mode = 'show';
@tracked modalOpen = false; @tracked modalOpen = false;
error = null; @tracked validations;
@tracked error = null;
get infoRows() { get infoRows() {
return [ return [
@ -43,38 +45,36 @@ export default class ConfigComponent extends Component {
} }
get modalTitle() { get modalTitle() {
let content = 'Turn usage tracking off?'; return `Turn usage tracking ${this.args.model.enabled.toLowerCase()}?`;
if (this.args.model && this.args.model.enabled === 'On') {
content = 'Turn usage tracking on?';
}
return content;
} }
@(task(function* () { @(task(function* () {
try { try {
yield this.args.model.save(); yield this.args.model.save();
this.router.transitionTo('vault.cluster.clients.config');
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
return; this.modalOpen = false;
} }
this.router.transitionTo('vault.cluster.clients.config');
}).drop()) }).drop())
save; save;
@action @action
updateBooleanValue(attr, value) { toggleEnabled(event) {
const valueToSet = value === true ? attr.options.trueValue : attr.options.falseValue; this.args.model.enabled = event.target.checked ? 'On' : 'Off';
this.args.model[attr.name] = valueToSet;
} }
@action @action
onSaveChanges(evt) { onSaveChanges(evt) {
evt.preventDefault(); evt.preventDefault();
const { isValid, state } = this.args.model.validate();
const changed = this.args.model.changedAttributes(); const changed = this.args.model.changedAttributes();
if (!changed.enabled) { if (!isValid) {
this.validations = state;
} else if (changed.enabled) {
this.modalOpen = true;
} else {
this.save.perform(); this.save.perform();
return;
} }
this.modalOpen = true;
} }
} }

View File

@ -19,6 +19,7 @@ import { get } from '@ember/object';
* options - an optional object for given validator -- min, max, nullable etc. -- see validators in util * options - an optional object for given validator -- min, max, nullable etc. -- see validators in util
* *
* message - string added to the errors array and returned from the validate method if validation fails * message - string added to the errors array and returned from the validate method if validation fails
* function may also be provided with model as single argument that returns a string
* *
* level - optional string that defaults to 'error'. Currently the only other accepted value is 'warn' * level - optional string that defaults to 'error'. Currently the only other accepted value is 'warn'
* *
@ -120,12 +121,14 @@ export function withModelValidations(validations) {
: validator(get(this, key), options); // dot notation may be used to define key for nested property : validator(get(this, key), options); // dot notation may be used to define key for nested property
if (!passedValidation) { if (!passedValidation) {
// message can also be a function
const validationMessage = typeof message === 'function' ? message(this) : message;
// consider setting a prop like validationErrors directly on the model // consider setting a prop like validationErrors directly on the model
// for now return an errors object // for now return an errors object
if (level === 'warn') { if (level === 'warn') {
state[key].warnings.push(message); state[key].warnings.push(validationMessage);
} else { } else {
state[key].errors.push(message); state[key].errors.push(validationMessage);
if (isValid) { if (isValid) {
isValid = false; isValid = false;
} }

View File

@ -4,32 +4,45 @@
*/ */
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import attachCapabilities from 'vault/lib/attach-capabilities'; import { withFormFields } from 'vault/decorators/model-form-fields';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { withModelValidations } from 'vault/decorators/model-validations';
import { apiPath } from 'vault/macros/lazy-capabilities';
const M = Model.extend({ const validations = {
queriesAvailable: attr('boolean'), // true only if historical data exists, will be false if there is only current month data retentionMonths: [
retentionMonths: attr('number', { {
validator: (model) => parseInt(model.retentionMonths) >= model.minimumRetentionMonths,
message: (model) =>
`Retention period must be greater than or equal to ${model.minimumRetentionMonths}.`,
},
],
};
@withModelValidations(validations)
@withFormFields(['enabled', 'retentionMonths'])
export default class ClientsConfigModel extends Model {
@attr('boolean') queriesAvailable; // true only if historical data exists, will be false if there is only current month data
@attr('number', {
label: 'Retention period', label: 'Retention period',
subText: 'The number of months of activity logs to maintain for client tracking.', subText: 'The number of months of activity logs to maintain for client tracking.',
}), })
enabled: attr('string', { retentionMonths;
editType: 'boolean',
trueValue: 'On',
falseValue: 'Off',
label: 'Enable usage data collection',
helpText:
'Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current month.',
}),
configAttrs: computed(function () { @attr('number') minimumRetentionMonths;
const keys = ['enabled', 'retentionMonths'];
return expandAttributeMeta(this, keys);
}),
});
export default attachCapabilities(M, { @attr('string') enabled;
configPath: apiPath`sys/internal/counters/config`,
}); @attr('boolean') reportingEnabled;
@attr('date') billingStartTimestamp;
@lazyCapabilities(apiPath`sys/internal/counters/config`) configPath;
get canRead() {
return this.configPath.get('canRead') !== false;
}
get canEdit() {
return this.configPath.get('canUpdate') !== false;
}
}

View File

@ -1,80 +1,38 @@
{{#if (eq @mode "edit")}} {{#if (eq @mode "edit")}}
<form onsubmit={{action "onSaveChanges"}} data-test-pricing-metrics-config-form> <form onsubmit={{action "onSaveChanges"}} data-test-clients-config-form>
<div class="box is-sideless is-fullwidth is-marginless"> <div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{@model}} @errorMessage={{this.error}} /> <MessageError @model={{@model}} @errorMessage={{this.error}} />
{{#each @model.configAttrs as |attr|}} {{#each @model.formFields as |attr|}}
{{#if (and (eq attr.type "string") (eq attr.options.editType "boolean"))}} {{#if (eq attr.name "enabled")}}
<label class="is-label">Usage data collection</label> <label class="is-label">Usage data collection</label>
{{#if attr.options.helpText}} <p class="sub-text">
<p class="sub-text"> Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current
{{attr.options.helpText}} month.
{{#if attr.options.docLink}} </p>
<DocLink @path={{attr.options.docLink}}>
See our documentation
</DocLink>
for help.
{{/if}}
</p>
{{/if}}
<div class="control is-flex has-bottom-margin-l"> <div class="control is-flex has-bottom-margin-l">
<input <input
data-test-field data-test-input="enabled"
type="checkbox" type="checkbox"
id={{attr.name}} id="enabled"
name={{attr.name}} name="enabled"
class="switch is-rounded is-success is-small" class="switch is-rounded is-success is-small"
checked={{eq (get @model attr.name) attr.options.trueValue}} disabled={{@model.reportingEnabled}}
onchange={{action (action "updateBooleanValue" attr) value="target.checked"}} checked={{eq @model.enabled "On"}}
{{on "change" this.toggleEnabled}}
/> />
<label for={{attr.name}}> <label for="enabled">
{{#if (eq @model.enabled "Off")}} Data collection is
Data collection is off {{lowercase @model.enabled}}
{{else}}
Data collection is on
{{/if}}
</label> </label>
</div> </div>
{{else if (eq attr.type "number")}} {{else}}
<div class="has-top-margin-s"> <FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.validations}} />
<label for={{attr.name}} class="is-label">
{{attr.options.label}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">
{{attr.options.subText}}
{{#if attr.options.docLink}}
<DocLink @path={{attr.options.docLink}}>
See our documentation
</DocLink>
for help.
{{/if}}
</p>
{{/if}}
<div class="control">
<input
data-test-field
id={{attr.name}}
disabled={{eq @model.enabled "Off"}}
autocomplete="off"
spellcheck="false"
onchange={{action (mut (get @model attr.name)) value="target.value"}}
value={{or (get @model attr.name) attr.options.defaultValue}}
class="input"
maxLength={{attr.options.characterLimit}}
/>
</div>
</div>
{{/if}} {{/if}}
{{/each}} {{/each}}
</div> </div>
<div class="field is-grouped-split box is-fullwidth is-bottomless"> <div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
<button <button type="submit" disabled={{this.buttonDisabled}} class="button is-primary" data-test-clients-config-save>
type="submit"
disabled={{this.buttonDisabled}}
class="button is-primary"
data-test-edit-metrics-config-save={{true}}
>
Save Save
</button> </button>
<LinkTo @route="vault.cluster.clients.config" class="button"> <LinkTo @route="vault.cluster.clients.config" class="button">
@ -83,6 +41,7 @@
</div> </div>
</div> </div>
</form> </form>
<Modal <Modal
@title={{this.modalTitle}} @title={{this.modalTitle}}
@onClose={{action (mut this.modalOpen) false}} @onClose={{action (mut this.modalOpen) false}}
@ -92,13 +51,13 @@
> >
<section class="modal-card-body"> <section class="modal-card-body">
{{#if (eq @model.enabled "On")}} {{#if (eq @model.enabled "On")}}
<p class="has-bottom-margin-s"> <p class="has-bottom-margin-s" data-test-clients-config-modal="on">
Vault will start tracking data starting from todays date, Vault will start tracking data starting from todays date,
{{date-format (now) "d MMMM yyyy"}}. You will not be able to see or query usage until the end of the month. {{date-format (now) "MMMM d, yyyy"}}. If youve previously enabled usage tracking, that historical data will still
be available to you.
</p> </p>
<p>If youve previously enabled usage tracking, that historical data will still be available to you.</p>
{{else}} {{else}}
<p class="has-bottom-margin-s"> <p class="has-bottom-margin-s" data-test-clients-config-modal="off">
Turning usage tracking off means that all data for the current month will be deleted. You will still be able to Turning usage tracking off means that all data for the current month will be deleted. You will still be able to
query previous months. query previous months.
</p> </p>
@ -106,24 +65,26 @@
{{/if}} {{/if}}
</section> </section>
<footer class="modal-card-foot modal-card-foot-outlined"> <footer class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button is-primary"
data-test-clients-config-modal="continue"
{{on "click" (perform this.save)}}
>
Continue
</button>
<button <button
type="button" type="button"
class="button is-secondary" class="button is-secondary"
onclick={{action (mut this.modalOpen) false}} {{on "click" (fn (mut this.modalOpen) false)}}
data-test-metrics-config-cancel data-test-clients-config-modal="cancel"
> >
Cancel Cancel
</button> </button>
<button type="button" class="button is-primary" onclick={{perform this.save}}>
Continue
</button>
</footer> </footer>
</Modal> </Modal>
{{else}} {{else}}
<div <div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-clients-config-table>
class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless"
data-test-pricing-metrics-config-table
>
{{#each this.infoRows as |item|}} {{#each this.infoRows as |item|}}
<InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{get @model item.valueKey}} /> <InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{get @model item.valueKey}} />
{{/each}} {{/each}}

View File

@ -32,7 +32,7 @@
@title="Data tracking is disabled" @title="Data tracking is disabled"
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration." @message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
> >
{{#if @model.config.configPath.canUpdate}} {{#if @model.config.canEdit}}
<p> <p>
<LinkTo @route="vault.cluster.clients.config"> <LinkTo @route="vault.cluster.clients.config">
Go to configuration Go to configuration

View File

@ -12,8 +12,11 @@
<LinkTo @route="vault.cluster.clients.dashboard" data-test-dashboard> <LinkTo @route="vault.cluster.clients.dashboard" data-test-dashboard>
Dashboard Dashboard
</LinkTo> </LinkTo>
{{#if (or @model.config.configPath.canRead @model.configPath.canRead)}} {{#if (or @model.config.canRead @model.canRead)}}
<LinkTo @route="vault.cluster.clients.config"> <LinkTo
@route="vault.cluster.clients.config"
@current-when="vault.cluster.clients.config vault.cluster.clients.edit"
>
Configuration Configuration
</LinkTo> </LinkTo>
{{/if}} {{/if}}

View File

@ -1,6 +1,6 @@
<Toolbar> <Toolbar>
<ToolbarActions> <ToolbarActions>
{{#if @model.configPath.canUpdate}} {{#if @model.canEdit}}
<LinkTo @route="vault.cluster.clients.edit" class="toolbar-link"> <LinkTo @route="vault.cluster.clients.edit" class="toolbar-link">
Edit configuration Edit configuration
</LinkTo> </LinkTo>

View File

@ -5,57 +5,42 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers'; import { render, find, click, fillIn } from '@ember/test-helpers';
import { resolve } from 'rsvp'; import { setupMirage } from 'ember-cli-mirage/test-support';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
module('Integration | Component | client count config', function (hooks) { module('Integration | Component | client count config', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
setupMirage(hooks);
const createAttr = (name, type, options) => {
return {
name,
type,
options,
};
};
const generateModel = (overrides) => {
return {
enabled: 'On',
retentionMonths: 24,
defaultReportMonths: 12,
configAttrs: [
createAttr('enabled', 'string', { editType: 'boolean' }),
createAttr('retentionMonths', 'number'),
],
changedAttributes: () => ({}),
save: () => {},
...overrides,
};
};
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.router = this.owner.lookup('service:router'); this.router = this.owner.lookup('service:router');
this.router.reopen({ this.transitionStub = sinon.stub(this.router, 'transitionTo');
transitionTo() { const store = this.owner.lookup('service:store');
return { this.createModel = (enabled = 'enable', reporting_enabled = false, minimum_retention_months = 0) => {
followRedirects() { store.pushPayload('clients/config', {
return resolve(); modelName: 'clients/config',
}, id: 'foo',
}; data: {
}, enabled,
}); reporting_enabled,
const model = generateModel(); minimum_retention_months,
this.model = model; retention_months: 24,
},
});
this.model = store.peekRecord('clients/config', 'foo');
};
}); });
test('it shows the table with the correct rows by default', async function (assert) { test('it shows the table with the correct rows by default', async function (assert) {
this.createModel();
await render(hbs`<Clients::Config @model={{this.model}} />`); await render(hbs`<Clients::Config @model={{this.model}} />`);
assert.dom('[data-test-pricing-metrics-config-table]').exists('Pricing metrics config table exists'); assert.dom('[data-test-clients-config-table]').exists('Clients config table exists');
const rows = document.querySelectorAll('.info-table-row'); const rows = document.querySelectorAll('.info-table-row');
assert.strictEqual(rows.length, 2, 'renders 2 infotable rows'); assert.strictEqual(rows.length, 2, 'renders 2 info table rows');
assert.ok( assert.ok(
find('[data-test-row-value="Usage data collection"]').textContent.includes('On'), find('[data-test-row-value="Usage data collection"]').textContent.includes('On'),
'Enabled value matches model' 'Enabled value matches model'
@ -66,72 +51,122 @@ module('Integration | Component | client count config', function (hooks) {
); );
}); });
test('TODO: it shows the config edit form when mode = edit', async function (assert) { test('it should function in edit mode when reporting is disabled', async function (assert) {
await render(hbs` assert.expect(13);
<div id="modal-wormhole"></div>
<Clients::Config @model={{this.model}} @mode="edit" />
`);
assert.dom('[data-test-pricing-metrics-config-form]').exists('Pricing metrics config form exists'); this.server.put('/sys/internal/counters/config', (schema, req) => {
const fields = document.querySelectorAll('[data-test-field]'); const { enabled, retention_months } = JSON.parse(req.requestBody);
assert.strictEqual(fields.length, 2, 'renders 2 fields'); const expected = { enabled: 'enable', retention_months: 5 };
}); assert.deepEqual(expected, { enabled, retention_months }, 'Correct data sent in PUT request');
return {};
test('it shows a modal with correct messaging when disabling', async function (assert) {
// Simulates the model when enabled value has been changed from On to Off
const simModel = generateModel({
enabled: 'Off',
changedAttributes: () => ({ enabled: ['On', 'Off'] }),
}); });
this.set('model', simModel);
this.createModel('disable');
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Config @model={{this.model}} @mode="edit" /> <Clients::Config @model={{this.model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); assert.dom('[data-test-input="enabled"]').isNotChecked('Data collection checkbox is not checked');
assert.dom('.modal.is-active').exists('Modal appears'); assert
.dom('label[for="enabled"]')
.hasText('Data collection is off', 'Correct label renders when data collection is off');
assert.dom('[data-test-input="retentionMonths"]').hasValue('24', 'Retention months render');
await click('[data-test-input="enabled"]');
await fillIn('[data-test-input="retentionMonths"]', -3);
await click('[data-test-clients-config-save]');
assert
.dom('[data-test-inline-error-message]')
.hasText(
'Retention period must be greater than or equal to 0.',
'Validation error shows for incorrect retention period'
);
await fillIn('[data-test-input="retentionMonths"]', 5);
await click('[data-test-clients-config-save]');
assert.dom('.modal.is-active').exists('Modal renders');
assert
.dom('[data-test-modal-title] span')
.hasText('Turn usage tracking on?', 'Correct modal title renders');
assert.dom('[data-test-clients-config-modal="on"]').exists('Correct modal description block renders');
await click('[data-test-clients-config-modal="continue"]');
assert.ok( assert.ok(
find('[data-test-modal-title]').textContent.includes('Turn usage tracking off?'), this.transitionStub.calledWith('vault.cluster.clients.config'),
'Modal confirming turn tracking off' 'Route transitions correctly on save success'
); );
await click('[data-test-metrics-config-cancel]');
assert.dom('.modal.is-active').doesNotExist('Modal goes away'); await click('[data-test-input="enabled"]');
await click('[data-test-clients-config-save]');
assert.dom('.modal.is-active').exists('Modal renders');
assert
.dom('[data-test-modal-title] span')
.hasText('Turn usage tracking off?', 'Correct modal title renders');
assert.dom('[data-test-clients-config-modal="off"]').exists('Correct modal description block renders');
await click('[data-test-clients-config-modal="cancel"]');
assert.dom('.modal.is-active').doesNotExist('Modal is hidden on cancel');
}); });
test('it shows a modal with correct messaging when enabling', async function (assert) { test('it should function in edit mode when reporting is enabled', async function (assert) {
// Simulates the model when enabled value has been changed from On to Off assert.expect(6);
const simModel = generateModel({
changedAttributes: () => ({ enabled: ['Off', 'On'] }), this.server.put('/sys/internal/counters/config', (schema, req) => {
const { enabled, retention_months } = JSON.parse(req.requestBody);
const expected = { enabled: 'enable', retention_months: 48 };
assert.deepEqual(expected, { enabled, retention_months }, 'Correct data sent in PUT request');
return {};
}); });
this.set('model', simModel);
this.createModel('enable', true, 24);
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Config @model={{this.model}} @mode="edit" /> <Clients::Config @model={{this.model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); assert.dom('[data-test-input="enabled"]').isChecked('Data collection input is checked');
assert.dom('.modal.is-active').exists('Modal appears'); assert
assert.ok( .dom('[data-test-input="enabled"]')
find('[data-test-modal-title]').textContent.includes('Turn usage tracking on?'), .isDisabled('Data collection input disabled when reporting is enabled');
'Modal confirming turn tracking on' assert
); .dom('label[for="enabled"]')
await click('[data-test-metrics-config-cancel]'); .hasText('Data collection is on', 'Correct label renders when data collection is on');
assert.dom('.modal.is-active').doesNotExist('Modal goes away'); assert.dom('[data-test-input="retentionMonths"]').hasValue('24', 'Retention months render');
await fillIn('[data-test-input="retentionMonths"]', 5);
await click('[data-test-clients-config-save]');
assert
.dom('[data-test-inline-error-message]')
.hasText(
'Retention period must be greater than or equal to 24.',
'Validation error shows for incorrect retention period'
);
await fillIn('[data-test-input="retentionMonths"]', 48);
await click('[data-test-clients-config-save]');
}); });
test('it does not show a modal on save if enable left unchanged', async function (assert) { test('it should not show modal when data collection is not changed', async function (assert) {
// Simulates the model when something other than enabled changed assert.expect(1);
const simModel = generateModel({
changedAttributes: () => ({ retentionMonths: [24, '48'] }), this.server.put('/sys/internal/counters/config', (schema, req) => {
const { enabled, retention_months } = JSON.parse(req.requestBody);
const expected = { enabled: 'enable', retention_months: 5 };
assert.deepEqual(expected, { enabled, retention_months }, 'Correct data sent in PUT request');
return {};
}); });
this.set('model', simModel);
this.createModel();
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Config @model={{this.model}} @mode="edit" /> <Clients::Config @model={{this.model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); await fillIn('[data-test-input="retentionMonths"]', 5);
assert.dom('.modal.is-active').doesNotExist('No modal appears'); await click('[data-test-clients-config-save]');
}); });
}); });