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

This commit is contained in:
Zhihe Li committed 2026-05-14 15:21:28 -04:00
1 parent add2a9d6c4
commit 4c15d72321
12 files changed
+1065 -32

No files matched your search

@@ -663,6 +663,16 @@ role:
edit-grants:
title: Edit Grants
description: Modify existing grant strings or add new grant strings to this role.
no-suggestions: No suggestions
completion-info:
wildcard-types: Wildcard - matches all types
wildcard-ids: Wildcard - matches all IDs
wildcard-actions: Wildcard - matches all actions
template-value: Template value
all-fields: All fields
editor:
title: Grant Editor
description: Add each new grant string on a different line
string-formats:
title: String format
title_plural: String formats
@@ -682,6 +692,7 @@ role:
insert-resource-ids: insert resource ids
export:
format: Format
formatted-export-aria-label: Formatted export
options:
terraform: Terraform
native-hcl: Native HCL
@@ -3,14 +3,58 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Rose::Form class='full-width' as |form|>
<Rose::Form
class='full-width'
@onSubmit={{fn @submit this.grantStrings}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
as |form|
>
<Hds::Layout::Grid
@columnWidth={{hash sm='100%' md='33.33%'}}
@gap='12'
as |LG|
>
<LG.Item @colspan={{hash sm=1 md=2}}>
{{! TODO: Add grants code editor. }}
<LG.Item @colspan={{hash sm=1 md=2}} @rowspan='2'>
<Hds::CodeEditor
class='grant-editor'
@hasFullScreenButton={{true}}
@ariaLabel={{t 'form.secret-editor.label'}}
@value={{this.grantStringsText}}
@onInput={{this.onInput}}
@customExtensions={{this.customExtensions}}
data-test-code-editor
as |CE|
>
<CE.Title>
{{t 'resources.role.edit-grants.editor.title'}}
</CE.Title>
<CE.Description>
{{t 'resources.role.edit-grants.editor.description'}}
</CE.Description>
</Hds::CodeEditor>
<form.actions class='margin-top-l'>
<:actions>
<Hds::ButtonSet>
<Hds::Button type='submit' @text={{t 'actions.save'}} />
<Hds::Button
type='button'
@text={{t 'actions.cancel'}}
@color='secondary'
{{on 'click' @cancel}}
/>
{{#if this.grantStringsText}}
<Hds::Button
@text={{t 'actions.export'}}
@color='tertiary'
@icon='upload'
data-test-export-grants-btn
{{on 'click' this.toggleExportOptionsFlyout}}
/>
{{/if}}
</Hds::ButtonSet>
</:actions>
</form.actions>
</LG.Item>
<Hds::Accordion @size='large' as |A|>
@@ -55,7 +99,9 @@
</Hds::Form::Select::Field>
<Hds::CodeBlock
@ariaLabel='formatted-export'
@ariaLabel={{t
'resources.role.edit-grants.export.formatted-export-aria-label'
}}
@hasCopyButton={{true}}
@language='hcl'
@value={{this.formattedExport}}
@@ -71,22 +117,4 @@
</M.Body>
</Hds::Flyout>
{{/if}}
<form.actions class='margin-top-l'>
<:actions>
<Hds::ButtonSet>
<Hds::Button type='submit' @text={{t 'actions.save'}} />
<Hds::Button @text={{t 'actions.cancel'}} @color='secondary' />
{{#if this.grantStringLines}}
<Hds::Button
@text={{t 'actions.export'}}
@color='tertiary'
@icon='upload'
data-test-export-grants-btn
{{on 'click' this.toggleExportOptionsFlyout}}
/>
{{/if}}
</Hds::ButtonSet>
</:actions>
</form.actions>
</Rose::Form>
@@ -4,22 +4,69 @@
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import {
autocompletion,
completionKeymap,
keymap,
} from '@hashicorp/design-system-components/codemirror';
import { createGrantCompletionSource } from 'admin/utils/grant-completions';
export default class FormRoleEditGrantsComponent extends Component {
@service intl;
export default class FormRoleEditGrants extends Component {
// =attributes
exportOptionsMap = { terraform: 'terraform', nativeHCL: 'native-hcl' };
exportOptions = Object.values(this.exportOptionsMap);
completionTranslatedStrings = {
noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'),
wildcardTypes: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-types',
),
wildcardIds: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-ids',
),
templateValue: this.intl.t(
'resources.role.edit-grants.completion-info.template-value',
),
wildcardActions: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-actions',
),
allFields: this.intl.t(
'resources.role.edit-grants.completion-info.all-fields',
),
};
// TODO: Replace with actual grant lines from code editor once implemented.
grantStringLines =
'ids=hc_123;type=host-catalog;actions=read,create,list\nids=ttcp_123;type=target;actions=list\ntype=credential;actions=create';
completionSource = createGrantCompletionSource(
this.args.grantsSchema,
this.completionTranslatedStrings,
);
@tracked grantStringsText = (this.args.model?.grant_strings ?? []).join('\n');
@tracked currentLineText = this.args.model?.grant_strings?.[0] ?? '';
@tracked showExportOptionsFlyout = false;
@tracked selectedExportOption = this.exportOptions[0];
customExtensions = [
autocompletion({
override: [this.completionSource],
// Trigger autocompletion when the user completes a grant field (which we labeled as keywords)
activateOnCompletion: (completion) => completion.type === 'keyword',
}),
keymap.of(completionKeymap),
];
get grantStrings() {
return this.grantStringsText
.split('\n')
.map((grantString) => grantString.trim())
.filter(Boolean);
}
/**
* Returns the formatted export based on the selected export option.
* @type {string}
@@ -37,7 +84,7 @@ export default class FormRoleEditGrants extends Component {
*/
get terraformFormattedExport() {
let formatted = `grant_strings = [ \n`;
this.grantStringLines.split('\n').forEach((line) => {
this.grantStringsText.split('\n').forEach((line) => {
formatted += ` "${line}",\n`;
});
formatted += `]\n`;
@@ -50,7 +97,7 @@ export default class FormRoleEditGrants extends Component {
*/
get nativeHclFormattedExport() {
let formatted = `[ \n`;
this.grantStringLines.split('\n').forEach((line) => {
this.grantStringsText.split('\n').forEach((line) => {
formatted += ` "${line}",\n`;
});
formatted += `]\n`;
@@ -59,6 +106,14 @@ export default class FormRoleEditGrants extends Component {
// =actions
@action
onInput(value, view) {
this.grantStringsText = value;
const line = view.state.doc.lineAt(view.state.selection.main.head);
this.currentLineText = line.text;
}
/**
* Toggles the export options flyout open and closed.
*/
@@ -0,0 +1,38 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeRolesRoleEditGrantsController extends Controller {
@service router;
/**
* Save grant strings on a role.
* @param {RoleModel} role
* @param {[string]} grantStrings
*/
@action
@loading
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.save-success')
async save(role, grantStrings) {
await role.saveGrantStrings(grantStrings);
this.router.replaceWith('scopes.scope.roles.role.grants');
}
/**
* Rollback changes on a role.
* @param {RoleModel} role
*/
@action
async cancel(role) {
role.rollbackAttributes();
this.router.replaceWith('scopes.scope.roles.role.grants');
}
}
@@ -4,5 +4,33 @@
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ScopesScopeRolesRoleEditGrantsRoute extends Route {}
export default class ScopesScopeRolesRoleEditGrantsRoute extends Route {
@service store;
async model() {
const role = this.modelFor('scopes.scope.roles.role');
let grantsSchema = { resource_types: [] };
try {
grantsSchema = await this.fetchGrantsSchema();
} catch {
// TODO: Return an error message
}
return { role, grantsSchema };
}
async fetchGrantsSchema() {
const adapter = this.store.adapterFor('application');
const url = `${adapter.host ?? ''}/grants-schema.json`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch grants schema');
}
return response.json();
}
}
+6
View File
@@ -996,3 +996,9 @@
}
}
}
.grant-editor {
.hds-code-editor__editor {
height: 200px;
}
}
@@ -28,7 +28,12 @@
</page.header>
<page.body>
<Form::Role::EditGrants />
<Form::Role::EditGrants
@model={{@model.role}}
@grantsSchema={{@model.grantsSchema}}
@submit={{fn this.save @model.role}}
@cancel={{fn this.cancel @model.role}}
/>
</page.body>
</Rose::Layout::Page>
+375
View File
@@ -0,0 +1,375 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields'];
const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}'];
const filterByPrefix = (values, partial) =>
values.filter((value) => value.startsWith(partial));
const withWildCard = (values = []) => ['*', ...new Set(values)];
const parseIds = (idsValue = '') => idsValue.split(',').filter(Boolean);
const getNoSuggestionsOption = (noSuggestionsLabel) => ({
label: noSuggestionsLabel,
type: 'text',
apply: () => {},
});
const parseGrantFields = (lineText) =>
lineText
.split(';')
.map((pair) => {
const [fieldName, ...valueParts] = pair.split('=');
// There should only be one '=' in a valid pair, but if there are more,
// we'll keep the value parts together and let the user know it's
// invalid through validation rather than breaking the parsing
return {
fieldName: fieldName,
fieldValue: valueParts.join('='),
};
})
.filter(({ fieldName }) => fieldName);
const normalizeGrantsSchema = (grantsSchema) => {
const resourceTypes = grantsSchema.resource_types ?? [];
const resourcesByType = resourceTypes.reduce((resources, resourceType) => {
const collectionActions = resourceType.collection_actions ?? [];
const idActions = resourceType.id_actions ?? [];
const idPrefixes = resourceType.id_prefixes ?? [];
resources[resourceType.type] = {
actions: [...collectionActions, ...idActions],
collectionActions,
idActions,
idPrefixes,
parentType: resourceType.parent_type,
};
return resources;
}, {});
const childResourceTypesByParentType = resourceTypes.reduce(
(childrenByParentType, resourceType) => {
const parentType = resourceType.parent_type;
if (!parentType) {
return childrenByParentType;
}
childrenByParentType[parentType] = [
...(childrenByParentType[parentType] ?? []),
resourceType.type,
];
return childrenByParentType;
},
{},
);
const resourceTypesByIdPrefix = resourceTypes.flatMap((resourceType) =>
(resourceType.id_prefixes ?? []).map((idPrefix) => ({
idPrefix,
type: resourceType.type,
})),
);
return {
resourceTypes,
resourcesByType,
childResourceTypesByParentType,
resourceTypesByIdPrefix,
};
};
const getResourceTypesForId = (schema, id) =>
id.includes('_')
? [
schema.resourceTypesByIdPrefix.find(
({ idPrefix }) => id.split('_')[0] === idPrefix,
)?.type,
].filter(Boolean)
: schema.resourceTypesByIdPrefix
.filter(({ idPrefix }) => idPrefix.startsWith(id))
.map(({ type }) => type);
const getCompatibleResourceTypeForIds = (schema, idsValue) => {
const resourceTypesById = parseIds(idsValue).flatMap((id) =>
getResourceTypesForId(schema, id),
);
const [firstResourceType] = resourceTypesById;
if (
!firstResourceType ||
!resourceTypesById.every(
(resourceType) => firstResourceType === resourceType,
)
) {
return null;
}
return firstResourceType;
};
const getIdActions = (schema, idsValue) => {
const matchedType = getCompatibleResourceTypeForIds(schema, idsValue);
if (!matchedType) {
return [];
}
return schema.resourcesByType[matchedType]?.idActions;
};
const getChildResourceActions = (schema, idsValue) => {
const parentType = getCompatibleResourceTypeForIds(schema, idsValue);
if (
!parentType ||
!schema.childResourceTypesByParentType[parentType]?.length
) {
return [];
}
const childTypes = schema.childResourceTypesByParentType[parentType] ?? [];
return [
...new Set(
childTypes.flatMap((type) => schema.resourcesByType[type]?.actions ?? []),
),
];
};
const getTypeOptions = (schema, idsValue) => {
// If there is no ids value, we can only suggest top-level resource types that have collection actions
if (!idsValue) {
return schema.resourceTypes
.filter(
(resource) =>
!resource.parent_type &&
resource.collection_actions.some(
(action) => action === 'list' || action === 'create',
),
)
.map((resource) => resource.type);
}
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
if (hasSpecificIds) {
const parentType = getCompatibleResourceTypeForIds(schema, idsValue);
if (!parentType) {
return [];
}
return schema.childResourceTypesByParentType[parentType] ?? [];
}
// Return the ones that have id based actions (currently only billing doesn't have them)
return schema.resourceTypes
.filter((resource) => resource.id_actions?.length > 0)
.map((resource) => resource.type);
};
const getActionOptions = (schema, typeValue, idsValue) => {
const actionOptions = withWildCard(
Object.values(schema.resourcesByType).flatMap(({ actions }) => actions),
);
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
const hasOnlyType = Boolean(typeValue) && !idsValue;
const selectedResource = schema.resourcesByType[typeValue];
// Just show ID specific actions as collection actions are invalid here
if (!typeValue && hasSpecificIds) {
const idActions = getIdActions(schema, idsValue);
return idActions.length ? withWildCard(idActions) : [];
}
if (!typeValue || (typeValue === '*' && idsValue === '*')) {
return actionOptions;
}
if (hasOnlyType) {
if (selectedResource?.parentType) {
return [];
}
return selectedResource?.collectionActions ?? [];
}
if (hasSpecificIds) {
// These are pinned IDs with a wildcard type
if (typeValue === '*') {
const childResourceActions = getChildResourceActions(schema, idsValue);
return childResourceActions.length
? withWildCard(childResourceActions)
: [];
}
const matchedType = getCompatibleResourceTypeForIds(schema, idsValue);
const isSelectedChildType = (
schema.childResourceTypesByParentType[matchedType] ?? []
).includes(typeValue);
// A specific ID paired with its own type is not a valid combination.
// For pinned IDs, only child types are valid.
if (!selectedResource || !isSelectedChildType) {
return [];
}
// Return all the actions for the selected type from a pinned ID
return withWildCard([
...selectedResource.collectionActions,
...selectedResource.idActions,
]);
}
// Should be wildcard IDs with a specific type, so show all actions for that type
return selectedResource
? withWildCard(selectedResource.actions)
: actionOptions;
};
function grantCompletions(context, schema, translatedStrings) {
const line = context.state.doc.lineAt(context.pos);
const beforeCursor = line.text.slice(0, context.pos - line.from);
const parsedFields = parseGrantFields(line.text);
const parsedFieldNames = parsedFields.map(({ fieldName }) => fieldName);
const idsValue = parsedFields.find(
({ fieldName }) => fieldName === 'ids',
)?.fieldValue;
const typeValue = parsedFields.find(
({ fieldName }) => fieldName === 'type',
)?.fieldValue;
// Check if we're typing a field name (e.g. "type=") by starting at the beginning or after a semicolon.
// This allows us to provide field name suggestions when the user is typing a new field
// and restrict suggestions to only fields that haven't been used yet in the current line
const fieldNameMatch = beforeCursor.match(/(?:^|;)([a-z_]*)$/);
if (fieldNameMatch) {
const partial = fieldNameMatch[1];
const options = filterByPrefix(
GRANT_FIELDS.filter((field) => !parsedFieldNames.includes(field)),
partial,
).map((field) => ({
label: `${field}=`,
type: 'keyword',
}));
return {
from: context.pos - partial.length,
options,
filter: false, // This is to preserve the original order of our array and not let codemirror sort it
};
}
// Check if we're providing a value for the "type" field
const typeValueMatch = beforeCursor.match(/type=([a-z-*]*)$/);
if (typeValueMatch) {
const partial = typeValueMatch[1];
const matchingTypeOptions = getTypeOptions(schema, idsValue);
const typeOptions =
partial === '' && matchingTypeOptions.length
? withWildCard(matchingTypeOptions)
: matchingTypeOptions;
const options = filterByPrefix(typeOptions, partial).map((type) => ({
label: type,
type: 'type',
info: type === '*' ? translatedStrings.wildcardTypes : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translatedStrings.noSuggestions)],
};
}
const idsValueMatch = beforeCursor.match(/ids=([a-z0-9_*.{},-]*)$/);
if (idsValueMatch) {
const enteredIds = idsValueMatch[1].split(',');
const partial = enteredIds.pop() ?? '';
const hasEnteredIds = enteredIds.length > 0;
// TODO: Add loaded ID suggestions here? Logic below will change
return {
from: context.pos - partial.length,
options: filterByPrefix(['*', ...ID_TEMPLATES], partial)
.filter((value) => !hasEnteredIds || value !== '*')
.filter((value) => !enteredIds.includes(value))
.map((value) => ({
label: value,
type: value === '*' ? 'constant' : 'interface',
info:
value === '*'
? translatedStrings.wildcardIds
: translatedStrings.templateValue,
})),
};
}
const actionsValueMatch = beforeCursor.match(/actions=([a-z0-9_:,*-]*)$/);
if (actionsValueMatch) {
const enteredActions = actionsValueMatch[1].split(',');
const partial = enteredActions.pop() ?? '';
const hasEnteredActions = enteredActions.length > 0;
const options = filterByPrefix(
getActionOptions(schema, typeValue, idsValue),
partial,
)
.filter((action) => !hasEnteredActions || action !== '*')
.filter((action) => !enteredActions.includes(action))
.map((action) => ({
label: action,
type: 'constant',
info: action === '*' ? translatedStrings.wildcardActions : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translatedStrings.noSuggestions)],
};
}
const outputFieldsMatch = beforeCursor.match(
/output_fields=([a-z0-9_,*-]*)$/,
);
if (outputFieldsMatch) {
const enteredOutputFields = outputFieldsMatch[1].split(',');
const partial = enteredOutputFields.pop() ?? '';
const hasEnteredOutputFields = enteredOutputFields.length > 0;
return {
from: context.pos - partial.length,
options: filterByPrefix(['*'], partial)
.filter((value) => !hasEnteredOutputFields || value !== '*')
.map((value) => ({
label: value,
type: 'constant',
info: translatedStrings.allFields,
})),
};
}
return null;
}
export const createGrantCompletionSource = (
grantsSchema,
translatedStrings,
) => {
const schema = normalizeGrantsSchema(grantsSchema);
return (context) => grantCompletions(context, schema, translatedStrings);
};
@@ -4,9 +4,10 @@
*/
import { module, test } from 'qunit';
import { visit, click, currentURL, select } from '@ember/test-helpers';
import { visit, click, currentURL, find, select, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import * as selectors from './selectors';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import { setRunOptions } from 'ember-a11y-testing/test-support';
module('Acceptance | roles/edit grants', function (hooks) {
@@ -28,6 +29,10 @@ module('Acceptance | roles/edit grants', function (hooks) {
instances.scopes.global = this.server.schema.scopes.find('global');
instances.role = this.server.create('role', {
scope: instances.scopes.global,
grant_strings: [
'ids=*;type=role;actions=read',
'ids=ttcp_1234567890;type=target;actions=authorize-session',
],
});
urls.role = `/scopes/global/roles/${instances.role.id}`;
urls.editGrants = `${urls.role}/edit-grants`;
@@ -122,4 +127,43 @@ module('Acceptance | roles/edit grants', function (hooks) {
assert.dom(selectors.EXPORT_FORMAT_SELECT).hasValue('native-hcl');
// TODO: Update to check for the actual formatted grant string.
});
test('loads current grants in the editor and updates them', async function (assert) {
assert.expect(4);
const newGrantString = 'ids=*;type=session;actions=list,read';
const expectedGrantStrings = [
...instances.role.grant_strings,
newGrantString,
];
await visit(urls.editGrants);
await waitFor(commonSelectors.CODE_EDITOR_CM);
assert
.dom(commonSelectors.CODE_EDITOR_CONTENT)
.includesText(instances.role.grant_strings[0]);
assert
.dom(commonSelectors.CODE_EDITOR_CONTENT)
.includesText(instances.role.grant_strings[1]);
const editorElement = find(commonSelectors.CODE_EDITOR_CODE);
const editorView = editorElement.editor;
editorView.dispatch({
changes: {
from: editorView.state.doc.length,
insert: `\n${newGrantString}`,
},
});
assert
.dom(commonSelectors.CODE_EDITOR_CONTENT)
.includesText(newGrantString);
await click(commonSelectors.SAVE_BTN);
assert.deepEqual(
this.server.schema.roles.find(instances.role.id).attrs.grantStrings,
expectedGrantStrings,
);
});
});
@@ -0,0 +1,87 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'admin/tests/helpers/mirage';
import { setupIntl } from 'ember-intl/test-support';
import { authenticateSession } from 'ember-simple-auth/test-support';
import sinon from 'sinon';
module(
'Unit | Controller | scopes/scope/roles/role/edit-grants',
function (hooks) {
setupTest(hooks);
setupMirage(hooks);
setupIntl(hooks, 'en-us');
let store;
let controller;
let router;
let replaceWithStub;
const instances = {
scopes: {
global: null,
},
role: null,
};
hooks.beforeEach(async function () {
store = this.owner.lookup('service:store');
router = this.owner.lookup('service:router');
replaceWithStub = sinon.stub(router, 'replaceWith');
controller = this.owner.lookup(
'controller:scopes/scope/roles/role/edit-grants',
);
instances.scopes.global = this.server.create(
'scope',
{ id: 'global' },
'withGlobalAuth',
);
await authenticateSession({
isGlobal: true,
account_id: this.server.schema.accounts.first().id,
});
instances.role = this.server.create('role', {
scope: instances.scopes.global,
});
});
test('it exists', function (assert) {
assert.ok(controller);
});
test('cancel action rolls-back changes on the specified model', async function (assert) {
const role = await store.findRecord('role', instances.role.id);
const originalGrantStrings = [...role.grant_strings];
const updatedGrantStrings = [
...originalGrantStrings,
'ids=*;type=*;actions=read',
];
role.grant_strings = updatedGrantStrings;
assert.notDeepEqual(role.grant_strings, originalGrantStrings);
assert.deepEqual(role.grant_strings, updatedGrantStrings);
await controller.cancel(role);
assert.deepEqual(role.grant_strings, originalGrantStrings);
assert.ok(
replaceWithStub.calledOnceWith('scopes.scope.roles.role.grants'),
);
});
test('save action saves grantStrings to specified model', async function (assert) {
const role = await store.findRecord('role', instances.role.id);
const grantStrings = role.grant_strings;
const newGrantStrings = [...grantStrings, 'ids=*;type=*;actions=read'];
await controller.save(role, newGrantStrings);
assert.deepEqual(role.grant_strings, newGrantStrings);
});
},
);
@@ -52,6 +52,7 @@ module('Unit | Controller | scopes/scope/roles/role/grants', function (hooks) {
assert.ok(controller.roles);
});
// TODO: Will be removed once this view becomes read only
test('cancel action rolls-back changes on the specified model', async function (assert) {
await visit(urls.grants);
const role = await store.findRecord('role', instances.role.id);
@@ -0,0 +1,355 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
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';
const createCompletionContext = (lineText) => ({
pos: lineText.length,
state: {
doc: {
lineAt() {
return {
text: lineText,
from: 0,
};
},
},
},
});
const getLabels = (completionResult) =>
completionResult.options.map(({ label }) => label);
const getInfos = (completionResult) =>
completionResult.options.map(({ info }) => info ?? '');
let allActionLabels;
module('Unit | Utils | grant-completions', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
setupIntl(hooks, 'en-us');
hooks.beforeEach(async function () {
this.intl = this.owner.lookup('service:intl');
this.completionTranslatedStrings = {
noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'),
wildcardTypes: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-types',
),
wildcardIds: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-ids',
),
wildcardActions: this.intl.t(
'resources.role.edit-grants.completion-info.wildcard-actions',
),
templateValue: this.intl.t(
'resources.role.edit-grants.completion-info.template-value',
),
allFields: this.intl.t(
'resources.role.edit-grants.completion-info.all-fields',
),
};
this.noSuggestionsLabel = this.completionTranslatedStrings.noSuggestions;
const response = await fetch('/grants-schema.json');
if (!response.ok) {
throw new Error(`Failed to fetch grants schema: ${response.status}`);
}
const grantsSchemaData = await response.json();
allActionLabels = grantsSchemaData.resource_types.reduce(
(labels, resource) => {
const resourceLabels = [
...(resource.collection_actions ?? []),
...(resource.id_actions ?? []),
];
return [...new Set([...labels, ...resourceLabels])];
},
[],
);
allActionLabels = ['*', ...allActionLabels];
this.grantCompletionSource = createGrantCompletionSource(
grantsSchemaData,
this.completionTranslatedStrings,
);
});
test.each(
'type suggestions',
{
'without ids only include top-level collection resource types': {
lineText: 'type=',
expectedLabels: [
'*',
'auth-method',
'session',
'credential-store',
'alias',
'user',
'auth-token',
'target',
'worker',
'group',
'role',
'host-catalog',
'storage-bucket',
'policy',
'scope',
'session-recording',
],
},
'with specific ids only include child resource types': {
lineText: 'ids=hcst_1234567890;type=',
expectedLabels: ['*', 'host', 'host-set'],
},
'with a partial id prefix include child resource types for the matched parent':
{
lineText: 'ids=hcs;type=',
expectedLabels: ['*', 'host', 'host-set'],
},
'with specific ids and no child resources show a no suggestions placeholder':
{
lineText: 'ids=hs_123;type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'with mixed resource ids show a no suggestions placeholder': {
lineText: 'ids=hs_123,s_123;type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'with specific ids and a partial type with no child resources show a no suggestions placeholder':
{
lineText: 'ids=hs_123;type=h',
expectedLabels: ['NO_SUGGESTIONS'],
},
},
function (assert, { lineText, expectedLabels }) {
const completionResult = this.grantCompletionSource(
createCompletionContext(lineText),
);
const resolvedLabels = expectedLabels.map((label) =>
label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label,
);
assert.deepEqual(getLabels(completionResult), resolvedLabels);
},
);
test.each(
'action suggestions',
{
'type-only grants only suggest collection actions for top-level resource types':
{
lineText: 'type=scope;actions=',
expectedLabels: [
'destroy-key-version',
'create',
'list',
'list-keys',
'rotate-keys',
'list-key-version-destruction-jobs',
],
},
'type-only grants suggest nothing for child resource types': {
lineText: 'type=host;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'specific ids without a type only suggest id actions for the resource type':
{
lineText: 'ids=hs_123;actions=',
expectedLabels: [
'*',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
'read',
'update',
],
},
'comma-separated ids of the same resource type suggest that type id actions':
{
lineText: 'ids=hs_123,hs_456;actions=',
expectedLabels: [
'*',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
'read',
'update',
],
},
'ids with a matching type show a no suggestions placeholder': {
lineText: 'ids=s_123;type=session;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'ids that conflict with the selected type show a no suggestions placeholder':
{
lineText: 'ids=hs_123;type=session;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'comma-separated ids with conflicting resource types show a no suggestions placeholder':
{
lineText: 'ids=hs_123,s_123;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'pinned ids with a specific child resource type suggest that type actions':
{
lineText: 'ids=hcst_1234567890;type=host-set;actions=',
expectedLabels: [
'*',
'create',
'list',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
'read',
'update',
],
},
'wildcard ids with a specific child resource type suggest that type actions':
{
lineText: 'ids=*;type=host-set;actions=',
expectedLabels: [
'*',
'create',
'list',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
'read',
'update',
],
},
'ids that are a top level resource that has child resources with a wildcard type suggest the union of child resource actions':
{
lineText: 'ids=hcst_1234567890;type=*;actions=',
expectedLabels: [
'*',
'create',
'list',
'read',
'update',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
],
},
'a partial parent id prefix with a wildcard type suggests child resource actions':
{
lineText: 'ids=hcs;type=*;actions=',
expectedLabels: [
'*',
'create',
'list',
'read',
'update',
'delete',
'add-hosts',
'set-hosts',
'remove-hosts',
],
},
'a partial parent id prefix with the same type shows a no suggestions placeholder':
{
lineText: 'ids=hcs;type=host-catalog;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'ids from different parent resource types with a wildcard type show a no suggestions placeholder':
{
lineText: 'ids=hcst_1234567890,o_123;type=*;actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'wildcard ids without a type suggest all actions': {
lineText: 'ids=*;actions=',
expectedLabels: () => allActionLabels,
},
'wildcard ids with a wildcard type suggest all actions': {
lineText: 'ids=*;type=*;actions=',
expectedLabels: () => allActionLabels,
},
'canonical template ids without a type show a no suggestions placeholder':
{
lineText: 'ids={{.User.Id}};actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
},
function (assert, { lineText, expectedLabels }) {
const completionResult = this.grantCompletionSource(
createCompletionContext(lineText),
);
const resolvedLabels = (
typeof expectedLabels === 'function' ? expectedLabels() : expectedLabels
).map((label) =>
label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label,
);
assert.deepEqual(getLabels(completionResult), resolvedLabels);
},
);
test.each(
'ids suggestions',
{
'include supported template values': {
lineText: 'ids={{',
expectedLabels: ['{{.User.Id}}', '{{.Account.Id}}'],
},
},
function (assert, { lineText, expectedLabels }) {
const completionResult = this.grantCompletionSource(
createCompletionContext(lineText),
);
assert.deepEqual(getLabels(completionResult), expectedLabels);
},
);
test('completion info labels are translated values', function (assert) {
const typeCompletionResult = this.grantCompletionSource(
createCompletionContext('type='),
);
const idsCompletionResult = this.grantCompletionSource(
createCompletionContext('ids='),
);
const outputFieldsCompletionResult = this.grantCompletionSource(
createCompletionContext('output_fields='),
);
const actionsCompletionResult = this.grantCompletionSource(
createCompletionContext('actions='),
);
assert.strictEqual(
typeCompletionResult.options[0].info,
this.completionTranslatedStrings.wildcardTypes,
);
assert.deepEqual(getInfos(idsCompletionResult), [
this.completionTranslatedStrings.wildcardIds,
this.completionTranslatedStrings.templateValue,
this.completionTranslatedStrings.templateValue,
]);
assert.strictEqual(
outputFieldsCompletionResult.options[0].info,
this.completionTranslatedStrings.allFields,
);
assert.strictEqual(
actionsCompletionResult.options[0].info,
this.completionTranslatedStrings.wildcardActions,
);
});
});