From f39bb43a4c8479dfc8c69c3a73ecb6e204c5ea1d Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Wed, 15 Oct 2025 19:07:58 -0400 Subject: [PATCH] UI: Fix JsonEditor updates after migration to Hds::CodeEditor (#10140) (#10166) * fix code editor updates on @value change * fix test failures * remove setRunOptions * use component "restore example" instead of custom one" * add test coverage * Revert "use component "restore example" instead of custom one"" This reverts commit 8e685f871cefd3f8a8be72b094b5e1b5fb93c894. * fix formfield reset action * remove remaining unused @container args * add comment * use helpText * add test coverage Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> --- .../components/modal-form/policy-template.hbs | 2 +- ui/app/components/policy-form.hbs | 2 +- ui/app/components/tools/unwrap.hbs | 1 - ui/app/components/tools/wrap.hbs | 2 +- ui/lib/core/addon/components/form-field.hbs | 5 +- ui/lib/core/addon/components/form-field.js | 11 +++- ui/lib/core/addon/components/json-editor.hbs | 10 +--- ui/lib/core/addon/components/json-editor.js | 46 +++++++++------- .../core/addon/components/policy-example.js | 3 +- .../components/page/role/create-and-edit.hbs | 12 +--- .../components/page/role/create-and-edit.js | 11 +++- .../integration/components/form-field-test.js | 10 +++- .../components/json-editor-test.js | 50 ++++++++++++----- .../page/role/create-and-edit-test.js | 55 ++++++++++++++++++- 14 files changed, 155 insertions(+), 65 deletions(-) diff --git a/ui/app/components/modal-form/policy-template.hbs b/ui/app/components/modal-form/policy-template.hbs index 0b6f0ec5f7..f191f14724 100644 --- a/ui/app/components/modal-form/policy-template.hbs +++ b/ui/app/components/modal-form/policy-template.hbs @@ -16,7 +16,7 @@ - + {{else}} diff --git a/ui/app/components/policy-form.hbs b/ui/app/components/policy-form.hbs index 10d6b1785e..46db130cb6 100644 --- a/ui/app/components/policy-form.hbs +++ b/ui/app/components/policy-form.hbs @@ -108,7 +108,7 @@ Policy - + diff --git a/ui/app/components/tools/unwrap.hbs b/ui/app/components/tools/unwrap.hbs index b83469d093..967d50d7f0 100644 --- a/ui/app/components/tools/unwrap.hbs +++ b/ui/app/components/tools/unwrap.hbs @@ -21,7 +21,6 @@ @title="Unwrapped Data" @value={{stringify this.unwrapData}} @readOnly={{true}} - @container=".toolbar-actions" /> diff --git a/ui/app/components/tools/wrap.hbs b/ui/app/components/tools/wrap.hbs index 21c60899f3..f0993a6787 100644 --- a/ui/app/components/tools/wrap.hbs +++ b/ui/app/components/tools/wrap.hbs @@ -51,7 +51,7 @@ diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index 8573a2742c..c1f3809381 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -490,14 +490,15 @@ @helpText={{this.helpTextString}} @mode={{@attr.options.mode}} @example={{@attr.options.example}} + @onSetup={{fn (mut this.codemirrorEditor)}} > {{#if @attr.options.allowReset}} {{/if}} diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 70c699c2b6..de766070a0 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -67,6 +67,7 @@ export default class FormFieldComponent extends Component { 'ttl', 'toggleButton', ]; + @tracked codemirrorEditor; @tracked showToggleTextInput = false; @tracked toggleInputEnabled = false; @@ -250,10 +251,16 @@ export default class FormFieldComponent extends Component { editorUpdated(isString, value) { try { const valToSet = isString ? value : JSON.parse(value); - this.args.model.set(this.valuePath, valToSet); - this.onChange(this.valuePath, valToSet); + + // Clicking "Clear" passes an empty string as the value and in that case we must manually reset the editor. + // At this time `allowReset` is only passed when `isString` is true. + if (value === '' && this.args.attr.options.allowReset && isString) { + this.codemirrorEditor.dispatch({ + changes: [{ from: 0, to: this.codemirrorEditor.state.doc.length, insert: '' }], + }); + } } catch { // if the value is not valid JSON, we don't want to set it on the model } diff --git a/ui/lib/core/addon/components/json-editor.hbs b/ui/lib/core/addon/components/json-editor.hbs index 175b7241c2..07b6f00ef0 100644 --- a/ui/lib/core/addon/components/json-editor.hbs +++ b/ui/lib/core/addon/components/json-editor.hbs @@ -27,7 +27,7 @@ @isLintingEnabled={{eq this.mode "json"}} @value={{or @value @example}} @onBlur={{@onBlur}} - @onInput={{this.onUpdate}} + @onInput={{@valueUpdated}} @onLint={{@onLint}} @onSetup={{this.onSetup}} as |CE| @@ -39,12 +39,6 @@ {{/if}} - {{#if @subTitle}} - - {{@subTitle}} - - {{/if}} - {{#if @helpText}} {{@helpText}} @@ -56,7 +50,7 @@ {{#if @example}} * * @param {string} [title] - Name above codemirror view - * @param {boolean} [showToolbar=true] - If false, toolbar and title are hidden * @param {string} [value] - a specific string the comes from codemirror. It's the value inside the codemirror display * @param {Function} [valueUpdated] - action to preform when you edit the codemirror value. * @param {Function} [onBlur] - action to preform when you focus out of codemirror. @@ -23,14 +23,21 @@ import { action } from '@ember/object'; * @param {Boolean} [readOnly] - Sets the view to readOnly, allowing for copying but no editing. It also hides the cursor. Defaults to false. * @param {String} [value] - Value within the display. Generally, a json string. * @param {string} [example] - Example to show when value is null -- when example is provided a restore action will render in the toolbar to clear the current value and show the example after input - * @param {string} [container] - **REQUIRED if rendering within a modal** Selector string or element object of containing element, set the focused element as the container value. This is for the Hds::Copy::Button and to set `autoRefresh=true` so content renders https://hds-website-hashicorp.vercel.app/components/copy/button?tab=code * @param {Function} [onSetup] - action to preform when the codemirror editor is setup. + * @param {Function} [onRestoreExample] - override callback to customize "Restore example" behavior. Default behavior is to reset @value to `null` * */ export default class JsonEditorComponent extends Component { _codemirrorEditor = null; + constructor() { + super(...arguments); + + const hasValueUpdated = !this.args.readOnly ? !!this.args.valueUpdated : true; + assert('@valueUpdated callback is required when component is not @readOnly', hasValueUpdated); + } + get mode() { return this.args.mode ?? 'json'; } @@ -51,24 +58,23 @@ export default class JsonEditorComponent extends Component { } @action - onUpdate(...args) { - if (!this.args.readOnly) { - // catching a situation in which the user is not readOnly and has not provided a valueUpdated function to the instance - this.args.valueUpdated(...args); + restoreExample() { + if (this.args.onRestoreExample) { + // Override to reset the @value of the code editor to something other than `null` + this.args.onRestoreExample(); + } else { + // Display @example in the editor but reset @value to `null` because + // sometimes @example is not valid to set and submit as the actual input value. + this._codemirrorEditor.dispatch({ + changes: [ + { + from: 0, + to: this._codemirrorEditor.state.doc.length, + insert: this.args.example, + }, + ], + }); + this.args.valueUpdated(null); } } - - @action - restoreExample() { - this._codemirrorEditor.dispatch({ - changes: [ - { - from: 0, - to: this._codemirrorEditor.state.doc.length, - insert: this.args.example, - }, - ], - }); - this.args.valueUpdated(null, this._codemirrorEditor); - } } diff --git a/ui/lib/core/addon/components/policy-example.js b/ui/lib/core/addon/components/policy-example.js index 159ddf89e3..05fd541c40 100644 --- a/ui/lib/core/addon/components/policy-example.js +++ b/ui/lib/core/addon/components/policy-example.js @@ -12,14 +12,13 @@ import Component from '@glimmer/component'; * (example below), otherwise the JsonEditor value won't render until it's focused. * * @example - * + * * @example * * @example * * * @param {string} policyType - policy type to decide which template to render; can either be "acl" or "rgp" - * @param {string} container - selector for the container the example renders inside, passed to the copy button in JsonEditor */ export default class PolicyExampleComponent extends Component { diff --git a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs index 7965302776..fb4ade83e5 100644 --- a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs +++ b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs @@ -112,19 +112,13 @@ data-test-rules @title="Role rules" @value={{template.rules}} + @example={{template.rules}} @mode="ruby" @valueUpdated={{fn (mut template.rules)}} @helpText={{sanitized-html this.roleRulesHelpText}} @onSetup={{fn (mut this.codemirrorEditor)}} - > - - + @onRestoreExample={{this.resetRoleRules}} + /> {{/let}} {{/if}} diff --git a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js index 4fe70b5f21..c193b6ecc5 100644 --- a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js +++ b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js @@ -112,14 +112,21 @@ export default class CreateAndEditRolePageComponent extends Component { @action resetRoleRules() { + // Reset tracked rule templates to initial values this.roleRulesTemplates = getRules(); + // Make sure editor renders the reset template + this.updateCodeMirror(); + } + @action + updateCodeMirror() { + const template = this.roleRulesTemplates.find((t) => t.id === this.selectedTemplateId); this.codemirrorEditor.dispatch({ changes: [ { from: 0, to: this.codemirrorEditor.state.doc.length, - insert: this.args.value, + insert: template.rules, }, ], }); @@ -128,6 +135,8 @@ export default class CreateAndEditRolePageComponent extends Component { @action selectTemplate(event) { this.selectedTemplateId = event.target.value; + // Dispatch the event to codemirror so the code editor updates when a template is selected + this.updateCodeMirror(); } @action diff --git a/ui/tests/integration/components/form-field-test.js b/ui/tests/integration/components/form-field-test.js index d758194639..4339510e6d 100644 --- a/ui/tests/integration/components/form-field-test.js +++ b/ui/tests/integration/components/form-field-test.js @@ -6,12 +6,13 @@ import EmberObject from '@ember/object'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, click, fillIn, findAll, setupOnerror } from '@ember/test-helpers'; +import { render, click, fillIn, findAll, setupOnerror, waitFor } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import { create } from 'ember-cli-page-object'; import sinon from 'sinon'; import formFields from '../../pages/components/form-field'; import { format, startOfDay } from 'date-fns'; +import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import AuthMethodForm from 'vault/forms/auth/method'; @@ -114,11 +115,16 @@ module('Integration | Component | form field', function (hooks) { assert.ok(component.hasJSONEditor, 'renders the json editor'); }); - test('it renders: string as json with clear button', async function (assert) { + test('it renders: string as json with clear button and resets input', async function (assert) { await setup.call(this, createAttr('foo', 'string', { editType: 'json', allowReset: true })); assert.dom('[data-test-component="json-editor-title"]').hasText('Foo', 'renders a label'); assert.ok(component.hasJSONEditor, 'renders the json editor'); assert.ok(component.hasJSONClearButton, 'renders button that will clear the JSON value'); + await waitFor('.cm-editor'); + const editor = codemirror(); + setCodeEditorValue(editor, 'some input'); + await click('[data-test-json-clear-button]'); + assert.strictEqual(getCodeEditorValue(editor), '', 'it clears the editor'); }); test('it renders: toggleButton', async function (assert) { diff --git a/ui/tests/integration/components/json-editor-test.js b/ui/tests/integration/components/json-editor-test.js index 17040dc106..a6df51258f 100644 --- a/ui/tests/integration/components/json-editor-test.js +++ b/ui/tests/integration/components/json-editor-test.js @@ -5,11 +5,10 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, click, triggerKeyEvent, waitFor } from '@ember/test-helpers'; +import { render, click, triggerKeyEvent, waitFor, setupOnerror } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import { SELECTORS } from '../../pages/components/json-editor'; import sinon from 'sinon'; -import { setRunOptions } from 'ember-a11y-testing/test-support'; import { createLongJson } from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror'; @@ -30,16 +29,6 @@ module('Integration | Component | json-editor', function (hooks) { this.set('bad_json_blob', BAD_JSON_BLOB); this.set('long_json', JSON.stringify(createLongJson(), null, `\t`)); this.set('hashi-read-only-theme', 'hashi-read-only auto-height'); - setRunOptions({ - rules: { - // CodeMirror has a secret textarea without a label that causes this problem - label: { enabled: false }, - // TODO: investigate and fix Codemirror styling - 'color-contrast': { enabled: false }, - // failing on .CodeMirror-scroll - 'scrollable-region-focusable': { enabled: false }, - }, - }); }); test('it renders', async function (assert) { @@ -56,6 +45,41 @@ module('Integration | Component | json-editor', function (hooks) { assert.dom(SELECTORS.codeBlock).exists('renders the code block'); }); + test('it requires @valueUpdated arg when component is not @readOnly', async function (assert) { + assert.expect(1); + // catch error so qunit test doesn't fail + setupOnerror((error) => { + assert.strictEqual( + error.message, + 'Assertion Failed: @valueUpdated callback is required when component is not @readOnly', + 'it throws error when component is editable (not @readOnly) and @valueUpdated is not provided' + ); + }); + await render(hbs``); + }); + + test('onSetup returns instance of codemirror', async function (assert) { + let codemirror; + this.onSetup = (editor) => { + codemirror = editor; + }; + await render(hbs` + + `); + await waitFor('.cm-editor'); + assert.true(codemirror.viewState.inView, 'onSetup returns instance of codemirror'); + }); + test('it should render example and restore it', async function (assert) { this.value = null; this.example = 'this is a test example'; @@ -69,8 +93,8 @@ module('Integration | Component | json-editor', function (hooks) { /> `); - let view = codemirror(); await waitFor('.cm-editor'); + let view = codemirror(); let editorValue = getCodeEditorValue(view); assert.strictEqual(editorValue, this.example, 'Example renders when there is no value'); diff --git a/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js b/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js index 273053455a..f01a55eff6 100644 --- a/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js +++ b/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js @@ -12,7 +12,7 @@ import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; import { setRunOptions } from 'ember-a11y-testing/test-support'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import codemirror, { setCodeEditorValue } from 'vault/tests/helpers/codemirror'; +import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror'; module('Integration | Component | kubernetes | Page::Role::CreateAndEdit', function (hooks) { setupRenderingTest(hooks); @@ -151,6 +151,49 @@ module('Integration | Component | kubernetes | Page::Role::CreateAndEdit', funct ); }); + test('it should update code editor when template selection changes', async function (assert) { + await render( + hbs``, + { owner: this.engine } + ); + + await click('[data-test-radio-card="full"]'); + await waitFor('.cm-editor'); + const editor = codemirror(); + const expectedInitialValue = `# The below is an example that you can use as a starting point. +# +# rules: +# - apiGroups: [""] +# resources: ["serviceaccounts", "serviceaccounts/token"] +# verbs: ["create", "update", "delete"] +# - apiGroups: ["rbac.authorization.k8s.io"] +# resources: ["rolebindings", "clusterrolebindings"] +# verbs: ["create", "update", "delete"] +# - apiGroups: ["rbac.authorization.k8s.io"] +# resources: ["roles", "clusterroles"] +# verbs: ["bind", "escalate", "create", "update", "delete"] +`; + assert.strictEqual( + getCodeEditorValue(editor), + expectedInitialValue, + 'editor initially renders rules from example template' + ); + // Select a different template + await fillIn('[data-test-select-template]', '6'); + const expectedRule = `rules: +- apiGroups: ['policy'] + resources: ['podsecuritypolicies'] + verbs: ['use'] + resourceNames: + - +`; + assert.strictEqual( + getCodeEditorValue(editor), + expectedRule, + 'code editor updates and renders rules from selected template' + ); + }); + test('it should create new role', async function (assert) { assert.expect(3); @@ -257,8 +300,16 @@ module('Integration | Component | kubernetes | Page::Role::CreateAndEdit', funct const editor = codemirror(); setCodeEditorValue(editor, addedText); await settled(); + assert.strictEqual(getCodeEditorValue(editor), addedText, 'code editor contains addedText'); await click('[data-test-restore-example]'); - assert.dom('.cm-content').doesNotContainText(addedText, 'Role rules example restored'); + const expectedValue = `rules: +- apiGroups: [""] + resources: ["secrets", "services"] + verbs: ["get", "watch", "list", "create", "delete", "deletecollection", "patch", "update"] +`; + assert.strictEqual(getCodeEditorValue(editor), expectedValue, 'code editor is reset to initial value'); + assert.strictEqual(this.role.generatedRoleRules, expectedValue, 'model value matches code editor'); + assert.dom('.cm-content').doesNotContainText(addedText, 'editor does not contain added text'); }); test('it should set generatedRoleRoles model prop on save', async function (assert) {