feat: 🎸 Add autocompletion to code editor (#3207)
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user