Grant actions card (#3246)

* chore: 🤖 Add initial page layout for code editor + sidebar (#3180)

* chore: 🤖 Add initial page layout for code editor + sidebar

* test: 💍 add missing a11y ignores for exisitng color issue

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18460

* Replace `Rose::CodeEditor` with `Hds::CodeEditor` (#2679)

* chore: 🤖 replace Rose::CodeEditor with Hds::CodeEditor

- Remove CodeEditor 5 dependency

* chore: 🤖 Replace CodeMirror5 dep

remove CodeMirror5 and replace existing editors with HDS::CodeEditor

* chore: 🤖 deleting old codeeditor styles

* feat: 🎸 changing code editor

changing code editor

✅ Closes: 0

* removed unused export

* pr cleanup

* updating tests for new code editor

* responding to PR feedback

* added whitespace

* fixing linting violations

* chore: 🤖 self-review PR

self-review PR in the course of switching to new HDS code editor

* fix: 🐛 fix linting bug

* fix: 🐛 unneeded dependency

remove jslint dependency as this is handled in the new code editor from
HDS

* fix: 🐛 Fixing import bug

Fix import bug caused by importing scss modules that no longer exist

* Fix and refactor code editor acceptance test

* Fix and refactor code editor acceptance test

* chore: 🤖 remove codemirror dependency

* refactor: 💡 fix merge conflict issues

* test: 💍 update e2e test selectors

* test: 💍 fix target spec click event

* refactor: 💡 clean up test selectors for code editor

* refactor: 💡 clean up storage bucket test selectors

* chore: 🤖 remove code mirror modifiers

---------

Co-authored-by: calcaide <ccorvo@hashicorp.com>
Co-authored-by: cameronperera <cameron.perera@hashicorp.com>

* feat: 🎸 Create string formats component (#3191)

* feat: 🎸 Create string formats component

* Add missing copyright headers

* fix: 🐛 change to readonly

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18458

* feat: 🎸 add grant schema to mirage (#3196)

* feat: 🎸 add grant schema to mirage

* refactor: 💡 update grant schema mirage data

* refactor: 💡 fix route name in mirage

* feat: 🎸 add export options flyout (#3198)

* feat: 🎸 add export options flyout

* fix: 🐛 update id to be ids

* test: 💍 add a11y ignores

* test: 💍 fix tests for grant string format

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18524

* feat: 🎸 Add autocompletion to code editor (#3207)

* feat: 🎸 create grant actions component

* feat: 🎸 add description to actions

* feat: 🎸 update default logic to not show all actions

* feat: 🎸 show invalid id and type message

* test: 💍 create const for test selectors

* feat: 🎸 trigger action suggestion when cursor moves

* refactor: 💡 add createGrantLineHelpers func

* refactor: 💡 small refactors

* feat: 🎸 Add CSP nonce to code editors (#3230)

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* refactor: 💡 small refactor

* test: 💍 add more tests

* refactor: 💡 fix merge conflicts issues

* refactor: 💡 updates from PR feedback

* refactor: 💡 updates for completions and linting

* test: 💍 fix test issue with translations

* test: 💍 fix test issue that was already fixed once

* refactor: 💡 address PR feedback

---------

Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
Co-authored-by: Zack Moore <zack.moore@hashicorp.com>
Co-authored-by: calcaide <ccorvo@hashicorp.com>
Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
Co-authored-by: Bharath Gajjala <120367134+bgajjala8@users.noreply.github.com>
This commit is contained in:
authored and Zhihe Li committed 2026-05-14 15:21:29 -04:00
1 parent 1a8c895ea9
commit 08b8b03fed
12 files changed
+880 -29

No files matched your search

@@ -173,4 +173,14 @@ module('Acceptance | roles/edit grants', function (hooks) {
expectedGrantStrings,
);
});
test('shows actions for the first grant on page load', async function (assert) {
await visit(urls.editGrants);
assert.dom(selectors.GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
// opted to not check for specific actions here since the grants schema
// may change and cause the test to fail even though the functionality
// is working as expected
assert.dom(selectors.GRANT_ACTIONS_TABLE).isVisible();
});
});
@@ -53,6 +53,11 @@ export const REMOVE_ORG_ONLY_BTN = (name) =>
export const REMOVE_ORG_PROJECTS_BTN = (name) =>
`[data-test-manage-scopes-remove-${name}-modal] div:nth-child(3) button`;
// Grant actions
export const GRANT_ACTIONS_TABLE = '[data-test-grant-actions-table]';
export const GRANT_ACTIONS_NO_TYPE_DETECTED =
'[data-test-grant-actions-no-type-detected]';
// Role grants
export const GRANT_CODE_BLOCK = '.grant-codeblock .hds-code-block__code';
export const EXPORT_OPTIONS_FLYOUT = '[data-test-export-options-flyout]';
@@ -0,0 +1,272 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'admin/tests/helpers';
import { setupIntl } from 'ember-intl/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import * as commonSelectors from 'admin/tests/helpers/selectors';
const GRANT_ACTIONS_TABLE = '[data-test-grant-actions-table]';
const GRANT_ACTIONS_EMPTY_STATE = '[data-test-grant-actions-empty-state]';
const GRANT_ACTIONS_NO_TYPE_DETECTED =
'[data-test-grant-actions-no-type-detected]';
const GRANT_ACTIONS_INVALID_ID_OR_TYPE =
'[data-test-grant-actions-invalid-id-or-type]';
module('Integration | Component | grant-actions/index', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');
hooks.beforeEach(function () {
this.grantsSchema = {
resource_types: [
{
type: 'host-catalog',
collection_actions: ['create', 'list'],
id_actions: ['read', 'update', 'delete'],
id_prefixes: ['hcst'],
},
{
type: 'host',
parent_type: 'host-catalog',
collection_actions: ['create', 'list'],
id_actions: ['read', 'update', 'delete'],
id_prefixes: ['h'],
},
{
type: 'host-set',
parent_type: 'host-catalog',
collection_actions: ['create', 'list'],
id_actions: [
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
'read',
'update',
],
id_prefixes: ['hs'],
},
],
};
});
test('it renders the actions relevant to the current grant line', async function (assert) {
this.grantString = 'ids=hcst_1234567890;type=host-set';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).isVisible();
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 8 });
const rows = [...this.element.querySelectorAll(commonSelectors.TABLE_ROWS)];
const rowData = rows.map((row) => {
const cells = [...row.querySelectorAll('td')].map((cell) =>
cell.textContent.trim(),
);
return {
name: cells[0],
description: cells[1],
};
});
assert.deepEqual(
rowData.map(({ name }) => name),
[
'add-hosts',
'create',
'delete',
'list',
'read',
'remove-hosts',
'set-hosts',
'update',
],
);
assert.deepEqual(rowData[0], {
name: 'add-hosts',
description: 'Add hosts to a host set',
});
assert.deepEqual(rowData[1], {
name: 'create',
description: 'Create a new host-set',
});
assert.deepEqual(rowData.at(-1), {
name: 'update',
description: 'Update a host-set',
});
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
});
test('it renders the no resource type detected state when the current line has no detectable type', async function (assert) {
this.grantString = 'output_fields=*';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist();
assert
.dom(GRANT_ACTIONS_NO_TYPE_DETECTED)
.hasText('No resource type detected.');
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
});
test('it renders the invalid ID and type state for an unknown type', async function (assert) {
this.grantString = 'type=not-a-real-type;actions=';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
assert
.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE)
.hasText('Invalid ID and type. No actions available.');
});
test('it renders the invalid ID and type state for an unknown ID', async function (assert) {
this.grantString = 'ids=bad_123;actions=';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
assert
.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE)
.hasText('Invalid ID and type. No actions available.');
});
test('it keeps the no suggestions state when a type is known but no actions apply', async function (assert) {
this.grantString = 'type=host;actions=';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist();
assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
assert.dom(GRANT_ACTIONS_EMPTY_STATE).hasText('No suggestions');
});
test('it renders the no-type-detected state for an empty grant string', async function (assert) {
this.grantString = '';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist();
assert
.dom(GRANT_ACTIONS_NO_TYPE_DETECTED)
.hasText('No resource type detected.');
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
});
test('it renders the no-type-detected state for template IDs', async function (assert) {
this.grantString = 'ids={{.User.Id}}';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist();
assert
.dom(GRANT_ACTIONS_NO_TYPE_DETECTED)
.hasText('No resource type detected.');
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
});
test('it renders the invalid state for a pinned id paired with its own type instead of a child type', async function (assert) {
this.grantString = 'ids=hcst_1234567890;type=host-catalog';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).doesNotExist();
assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist();
assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist();
assert
.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE)
.hasText('Invalid ID and type. No actions available.');
});
test('it renders only CRUDL actions with fallback descriptions when type is wildcard', async function (assert) {
this.grantString = 'ids=*;type=*';
await render(hbs`
<GrantActions
@grantsSchema={{this.grantsSchema}}
@grantString={{this.grantString}}
/>
`);
assert.dom(GRANT_ACTIONS_TABLE).isVisible();
const rows = [...this.element.querySelectorAll(commonSelectors.TABLE_ROWS)];
const rowNames = rows.map((row) =>
row.querySelector('td')?.textContent.trim(),
);
assert.deepEqual(
rowNames,
['create', 'delete', 'list', 'read', 'update'],
'only CRUDL actions are shown for wildcard grants',
);
const createRow = rows.find(
(row) => row.querySelector('td')?.textContent.trim() === 'create',
);
assert.strictEqual(
createRow.querySelectorAll('td')[1].textContent.trim(),
'Creates resources',
'description falls back to generic when no resource type is detected',
);
});
});
@@ -8,7 +8,13 @@ import { setupTest } from 'ember-qunit';
import { setupIntl } from 'ember-intl/test-support';
import { setupMirage } from 'admin/tests/helpers/mirage';
import { createGrantCompletionSource } from 'admin/utils/grant-completions';
import {
createGrantCompletionSource,
analyzeGrantString,
getChildResourceActions,
getCompatibleResourceTypeForIds,
normalizeGrantsSchema,
} from 'admin/utils/grant-completions';
const createCompletionContext = (lineText) => ({
pos: lineText.length,
@@ -74,6 +80,35 @@ module('Unit | Utils | grant-completions', function (hooks) {
);
});
test.each(
'field name suggestions',
{
'suggest all fields on an empty line': {
lineText: '',
expectedLabels: ['ids=', 'type=', 'actions=', 'output_fields='],
},
'filter already-used fields from suggestions': {
lineText: 'ids=*;',
expectedLabels: ['type=', 'actions=', 'output_fields='],
},
'match a partial field name': {
lineText: 'ty',
expectedLabels: ['type='],
},
'filter multiple already-used fields': {
lineText: 'ids=*;type=scope;',
expectedLabels: ['actions=', 'output_fields='],
},
},
async function (assert, { lineText, expectedLabels }) {
const completionResult = await this.grantCompletionSource(
createCompletionContext(lineText),
);
assert.deepEqual(getLabels(completionResult), expectedLabels);
},
);
test.each(
'type suggestions',
{
@@ -358,6 +393,35 @@ module('Unit | Utils | grant-completions', function (hooks) {
},
);
test.each(
'ids suggestions',
{
'include supported template values': {
lineText: 'ids={{',
expectedLabels: ['{{.User.Id}}', '{{.Account.Id}}'],
},
'suggest wildcard and templates on an empty ids value': {
lineText: 'ids=',
expectedLabels: ['*', '{{.User.Id}}', '{{.Account.Id}}'],
},
'filter wildcard once an id is already entered': {
lineText: 'ids={{.User.Id}},',
expectedLabels: ['{{.Account.Id}}'],
},
'filter already-entered template ids when completing a partial': {
lineText: 'ids={{.User.Id}},{{',
expectedLabels: ['{{.Account.Id}}'],
},
},
async function (assert, { lineText, expectedLabels }) {
const completionResult = await this.grantCompletionSource(
createCompletionContext(lineText),
);
assert.deepEqual(getLabels(completionResult), expectedLabels);
},
);
test('completion info labels are translated values', async function (assert) {
const typeCompletionResult = await this.grantCompletionSource(
createCompletionContext('type='),
@@ -394,6 +458,240 @@ module('Unit | Utils | grant-completions', function (hooks) {
);
});
test('analyzeGrantString detects resource type and actions for a complete grant string', function (assert) {
const result = analyzeGrantString(
this.grantsSchemaData,
'ids=hcst_1234567890;type=host-set;actions=read,list;output_fields=*',
);
assert.strictEqual(result.detectedResourceType, 'host-set');
assert.false(result.hasInvalidType);
assert.false(result.hasInvalidIds);
assert.true(result.actions.length > 0);
});
test('analyzeGrantString returns no actions and no detected type for output_fields only', function (assert) {
const result = analyzeGrantString(this.grantsSchemaData, 'output_fields=*');
assert.strictEqual(result.detectedResourceType, null);
assert.deepEqual(result.actions, []);
});
test('analyzeGrantString returns no actions and no detected type for an empty string', function (assert) {
const result = analyzeGrantString(this.grantsSchemaData, '');
assert.strictEqual(result.detectedResourceType, null);
assert.deepEqual(result.actions, []);
});
test('getCompatibleResourceTypeForIds returns the matched type for a known id prefix', function (assert) {
const schema = normalizeGrantsSchema(this.grantsSchemaData);
assert.strictEqual(
getCompatibleResourceTypeForIds(schema, 'hs_123'),
'host-set',
);
});
test('getCompatibleResourceTypeForIds returns null for an unknown id prefix', function (assert) {
const schema = normalizeGrantsSchema(this.grantsSchemaData);
assert.strictEqual(
getCompatibleResourceTypeForIds(schema, 'bad_123'),
null,
);
});
test('getCompatibleResourceTypeForIds returns null for mixed resource type ids', function (assert) {
const schema = normalizeGrantsSchema(this.grantsSchemaData);
assert.strictEqual(
getCompatibleResourceTypeForIds(schema, 'hs_123,s_123'),
null,
);
});
test('getCompatibleResourceTypeForIds returns the type when all ids share the same resource type', function (assert) {
const schema = normalizeGrantsSchema(this.grantsSchemaData);
assert.strictEqual(
getCompatibleResourceTypeForIds(schema, 'hs_123,hs_456'),
'host-set',
);
});
test('analyzeGrantString.actions returns the same actions as the completion source uses', function (assert) {
const { actions } = analyzeGrantString(
this.grantsSchemaData,
'ids=hcst_1234567890;type=host-set',
);
assert.deepEqual(actions, [
'add-hosts',
'create',
'delete',
'list',
'read',
'remove-hosts',
'set-hosts',
'update',
]);
});
test('analyzeGrantString.detectedResourceType returns the type inferred from a specific id', function (assert) {
const { detectedResourceType } = analyzeGrantString(
this.grantsSchemaData,
'ids=hs_123',
);
assert.strictEqual(detectedResourceType, 'host-set');
});
test('analyzeGrantString.detectedResourceType returns an explicit type value', function (assert) {
const { detectedResourceType } = analyzeGrantString(
this.grantsSchemaData,
'type=session',
);
assert.strictEqual(detectedResourceType, 'session');
});
test('analyzeGrantString.detectedResourceType returns null when no type can be detected', function (assert) {
const { detectedResourceType } = analyzeGrantString(
this.grantsSchemaData,
'output_fields=*',
);
assert.strictEqual(detectedResourceType, null);
});
test('analyzeGrantString.detectedResourceType returns null for a wildcard type', function (assert) {
const { detectedResourceType } = analyzeGrantString(
this.grantsSchemaData,
'type=*',
);
assert.strictEqual(detectedResourceType, null);
});
test('analyzeGrantString treats a type with additional equals signs as invalid', function (assert) {
const result = analyzeGrantString(
this.grantsSchemaData,
'ids=s_123;type=session=a;actions=read',
);
assert.true(result.hasInvalidType);
});
test('getChildResourceActions returns the union of child resource actions for a pinned parent id', function (assert) {
const schema = normalizeGrantsSchema(this.grantsSchemaData);
assert.deepEqual(getChildResourceActions(schema, 'hcst_1234567890'), [
'create',
'list',
'read',
'update',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
]);
});
test.each(
'analyzeGrantString.hasInvalidType',
{
'returns true for an unknown type': {
grantString: 'type=not-a-real-type',
expected: true,
},
'returns false for a known type': {
grantString: 'type=host-set',
expected: false,
},
'returns false for a wildcard type': {
grantString: 'type=*',
expected: false,
},
'returns false when there is no type': {
grantString: 'ids=hs_123',
expected: false,
},
},
function (assert, { grantString, expected }) {
const { hasInvalidType } = analyzeGrantString(
this.grantsSchemaData,
grantString,
);
assert.strictEqual(hasInvalidType, expected);
},
);
test.each(
'analyzeGrantString.hasInvalidIds',
{
'returns true for an unrecognised id prefix': {
grantString: 'ids=bad_123',
expected: true,
},
'returns true for mixed ids with conflicting resource types': {
grantString: 'ids=hs_123,s_123',
expected: true,
},
'returns false for a recognised id prefix': {
grantString: 'ids=hs_123',
expected: false,
},
'returns false for a wildcard id': {
grantString: 'ids=*',
expected: false,
},
'returns false for a template id': {
grantString: 'ids={{.User.Id}}',
expected: false,
},
},
function (assert, { grantString, expected }) {
const { hasInvalidIds } = analyzeGrantString(
this.grantsSchemaData,
grantString,
);
assert.strictEqual(hasInvalidIds, expected);
},
);
test.each(
'analyzeGrantString.hasInvalidPinnedIdTypeCombination',
{
'returns true when a pinned id is paired with its own type': {
grantString: 'ids=hcst_1234567890;type=host-catalog',
expected: true,
},
'returns false when a pinned id is paired with a valid child type': {
grantString: 'ids=hcst_1234567890;type=host-set',
expected: false,
},
'returns false for wildcard ids with a specific type': {
grantString: 'ids=*;type=host-set',
expected: false,
},
'returns false when there is no type': {
grantString: 'ids=hcst_1234567890',
expected: false,
},
'returns false when ids are invalid': {
grantString: 'ids=bad_123;type=host-set',
expected: false,
},
},
function (assert, { grantString, expected }) {
const { hasInvalidPinnedIdTypeCombination } = analyzeGrantString(
this.grantsSchemaData,
grantString,
);
assert.strictEqual(hasInvalidPinnedIdTypeCombination, expected);
},
);
module('id auto completion', function () {
test('idLookup results are included alongside static options', async function (assert) {
const source = createGrantCompletionSource(