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>
This commit is contained in:
Vault Automation 2025-10-15 19:07:58 -04:00 committed by GitHub
parent 4617d3fd16
commit f39bb43a4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 155 additions and 65 deletions

View File

@ -16,7 +16,7 @@
<PolicyForm @onSave={{this.onSave}} @model={{this.policy}} @onCancel={{@onCancel}} />
</T.Panel>
<T.Panel class="has-top-padding-m">
<PolicyExample @policyType={{this.policy.policyType}} @container="#search-select-modal" />
<PolicyExample @policyType={{this.policy.policyType}} />
</T.Panel>
</Hds::Tabs>
{{else}}

View File

@ -108,7 +108,7 @@
Policy
</M.Header>
<M.Body>
<PolicyExample @policyType={{@model.policyType}} @container="#policy-example-modal" />
<PolicyExample @policyType={{@model.policyType}} />
</M.Body>
<M.Footer as |F|>
<Hds::Button @text="Close" {{on "click" F.close}} data-test-modal-close-button />

View File

@ -21,7 +21,6 @@
@title="Unwrapped Data"
@value={{stringify this.unwrapData}}
@readOnly={{true}}
@container=".toolbar-actions"
/>
</T.Panel>
<T.Panel>

View File

@ -51,7 +51,7 @@
<JsonEditor
class="has-top-margin-s"
@title="Data to wrap"
@subTitle="json-formatted"
@helpText="json-formatted"
@value={{this.stringifiedWrapData}}
@valueUpdated={{this.editorUpdated}}
/>

View File

@ -490,14 +490,15 @@
@helpText={{this.helpTextString}}
@mode={{@attr.options.mode}}
@example={{@attr.options.example}}
@onSetup={{fn (mut this.codemirrorEditor)}}
>
{{#if @attr.options.allowReset}}
<Hds::Button
@text="Clear"
@icon="reload"
class="toolbar-button"
@color="secondary"
disabled={{not value}}
{{on "click" this.setAndBroadcast}}
{{on "click" (fn this.editorUpdated true "")}}
data-test-json-clear-button
/>
{{/if}}

View File

@ -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
}

View File

@ -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 @@
</CE.Title>
{{/if}}
{{#if @subTitle}}
<CE.Description>
{{@subTitle}}
</CE.Description>
{{/if}}
{{#if @helpText}}
<CE.Description>
{{@helpText}}
@ -56,7 +50,7 @@
{{#if @example}}
<Hds::Button
class="toolbar-button"
@color="secondary"
@icon="reload"
@text="Restore example"
disabled={{not @value}}

View File

@ -5,6 +5,7 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { assert } from '@ember/debug';
/**
* @module JsonEditor
@ -13,7 +14,6 @@ import { action } from '@ember/object';
* <JsonEditor @title="Policy" @value={{hash foo="bar"}} @viewportMargin={{100}} />
*
* @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);
}
}

View File

@ -12,14 +12,13 @@ import Component from '@glimmer/component';
* (example below), otherwise the JsonEditor value won't render until it's focused.
*
* @example
* <PolicyExample @policyType="acl" @container="#search-select-modal" />
* <PolicyExample @policyType="acl" />
* @example
* <PolicyExample @policyType="rgp" />
* @example
* <PolicyExample @policyType="egp" />
*
* @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 {

View File

@ -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)}}
>
<Hds::Button
@icon="reload"
@text="Restore example"
@color="secondary"
{{on "click" this.resetRoleRules}}
data-test-restore-example
/>
</JsonEditor>
@onRestoreExample={{this.resetRoleRules}}
/>
{{/let}}
</div>
{{/if}}

View File

@ -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

View File

@ -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) {

View File

@ -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`<JsonEditor
@value={{"{}"}}
@title={{"Test title"}}
@showToolbar={{true}}
/>`);
});
test('onSetup returns instance of codemirror', async function (assert) {
let codemirror;
this.onSetup = (editor) => {
codemirror = editor;
};
await render(hbs`
<JsonEditor
@value={{this.value}}
@example={{this.example}}
@mode="ruby"
@valueUpdated={{fn (mut this.value)}}
@onSetup={{this.onSetup}}
/>
`);
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');

View File

@ -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`<Page::Role::CreateAndEdit @model={{this.newModel}} @breadcrumbs={{this.breadcrumbs}} />`,
{ 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:
- <list of policies to authorize>
`;
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) {