Icu 18506 load resource IDs for autocomplete (#3247)

* feat: 🎸 Add id auto completion
* style: 💄 Fix code editor styling for dark mode
* feat: 🎸 Add optimization for caching sqlite data

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18506
This commit is contained in:
Zhihe Li committed 2026-05-14 15:21:29 -04:00
1 parent 1d126dc074
commit e5657a8d4c
11 files changed
+860 -128

No files matched your search

@@ -22,6 +22,7 @@
@ariaLabel={{t 'form.secret-editor.label'}}
@value={{this.grantStringsText}}
@onInput={{this.onInput}}
@onSetup={{this.onSetup}}
@customExtensions={{this.customExtensions}}
@cspNonce={{(csp-nonce)}}
data-test-code-editor
@@ -17,50 +17,155 @@ import {
} from '@hashicorp/design-system-components/codemirror';
import { createGrantCompletionSource } from 'admin/utils/grant-completions';
import { createGrantLinter } from 'admin/utils/grant-linter';
import {
GRANT_SCOPE_THIS,
GRANT_SCOPE_CHILDREN,
GRANT_SCOPE_DESCENDANTS,
GRANT_SCOPE_KEYWORDS,
} from 'api/models/role';
export default class FormRoleEditGrantsComponent extends Component {
@service intl;
@service db;
// =attributes
#editorView;
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',
),
};
translateLintingError = (key, options = {}) =>
this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options);
completionSource = createGrantCompletionSource(
this.args.grantsSchema,
this.completionTranslatedStrings,
);
linterSource = createGrantLinter(
this.args.grantsSchema,
this.translateLintingError,
);
@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];
/**
* Returns the scope IDs that should be used to filter ID suggestions.
* @returns {Promise<string[]|null>}
*/
async getAllowedScopeIds() {
const grantScopeIds = this.args.model?.grant_scope_ids ?? [];
const roleScopeId = this.args.model?.scopeID;
// Descendants covers the entire subtree so no need to filter
if (grantScopeIds.includes(GRANT_SCOPE_DESCENDANTS)) {
return null;
}
const scopeIds = new Set();
if (grantScopeIds.includes(GRANT_SCOPE_THIS)) {
scopeIds.add(roleScopeId);
}
if (grantScopeIds.includes(GRANT_SCOPE_CHILDREN)) {
// Grab direct children scopes
try {
const childScopes = await this.db.query('scope', {
query: { filters: { scope_id: [{ equals: roleScopeId }] } },
select: [{ field: 'id' }],
});
childScopes.forEach(({ id }) => {
scopeIds.add(id);
});
} catch {
// In cases of errors, just return all IDs without filtering rather than blocking completions entirely
return null;
}
}
// Any concrete org/project IDs specified directly
grantScopeIds
.filter((id) => !GRANT_SCOPE_KEYWORDS.includes(id))
.forEach((id) => scopeIds.add(id));
return scopeIds.size ? [...scopeIds] : null;
}
getIsLoading = () => {
return this.args.loadResourcesTask?.isRunning ?? false;
};
/**
* Looks up resource IDs from the local DB.
* @param {string} partial - The current partial value the user has typed
* @param {string[]|null} [compatibleResourceTypes] - Resource types to restrict results to
* @returns {Promise<Array<{id: string, name: string|undefined}>>}
*/
idLookup = async (partial, compatibleResourceTypes) => {
// When the partial already contains an underscore we know the ID prefix
// (e.g. "ttcp_") and can narrow down which types to query.
let typesToQuery;
if (partial.includes('_')) {
const idPrefix = partial.split('_')[0];
const matchingGrantTypes = (this.args.grantsSchema?.resource_types ?? [])
.filter((rt) => rt.id_prefixes?.includes(idPrefix))
.map((rt) => rt.type);
typesToQuery = (this.args.searchableResourceTypes ?? []).filter((t) =>
matchingGrantTypes.includes(t),
);
} else {
typesToQuery = this.args.searchableResourceTypes ?? [];
}
// Restrict to the compatible resource types if provided
if (compatibleResourceTypes) {
typesToQuery = typesToQuery.filter((t) =>
compatibleResourceTypes.includes(t),
);
}
if (!typesToQuery.length) {
return [];
}
const scopeIds = await this.getAllowedScopeIds();
const filters = {};
if (scopeIds) {
filters.scope_id = scopeIds.map((id) => ({ equals: id }));
}
const results = await Promise.allSettled(
typesToQuery.map((type) =>
this.db.query(type, {
query: {
// Search both id and name so a user can type a resource name and
// still get the ID inserted as the completion
search: partial
? { text: partial, fields: ['id', 'name'] }
: undefined,
filters,
},
page: 1,
pageSize: 10,
select: [{ field: 'id' }, { field: 'name' }],
}),
),
);
return results
.filter(({ status }) => status === 'fulfilled')
.flatMap(({ value }) => value)
.map(({ id, name }) => ({ id, name }));
};
translateLintingError = (key, options = {}) =>
this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options);
translateCompletionString = (key, options = {}) =>
this.intl.t(`resources.role.edit-grants.completion-info.${key}`, options);
completionSource = createGrantCompletionSource(
this.args.grantsSchema,
this.translateCompletionString,
this.idLookup,
this.getIsLoading,
);
linterSource = createGrantLinter(
this.args.grantsSchema,
this.translateLintingError,
);
customExtensions = [
autocompletion({
override: [this.completionSource],
@@ -72,6 +177,11 @@ export default class FormRoleEditGrantsComponent extends Component {
keymap.of([...completionKeymap, ...lintKeymap]),
];
willDestroy() {
super.willDestroy();
this.#editorView?.destroy();
}
get grantStrings() {
return this.grantStringsText
.split('\n')
@@ -118,6 +228,11 @@ export default class FormRoleEditGrantsComponent extends Component {
// =actions
@action
onSetup(view) {
this.#editorView = view;
}
@action
onInput(value, view) {
this.grantStringsText = value;
@@ -5,6 +5,16 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { dropTask } from 'ember-concurrency';
import { modelMapping } from 'api/services/sqlite';
import { notifyError } from 'core/decorators/notify';
// Only resource types that have a name column are useful for ID suggestions
// Currently this excludes session-recording and sessions as they aren't likely to be used
// and could be expensive to load.
const NAMED_RESOURCE_TYPES = Object.keys(modelMapping).filter(
(type) => 'name' in modelMapping[type],
);
export default class ScopesScopeRolesRoleEditGrantsRoute extends Route {
@service store;
@@ -13,15 +23,32 @@ export default class ScopesScopeRolesRoleEditGrantsRoute extends Route {
const role = this.modelFor('scopes.scope.roles.role');
let grantsSchema = { resource_types: [] };
try {
grantsSchema = await this.fetchGrantsSchema();
} catch {
// TODO: Return an error message
}
grantsSchema = await this.fetchGrantsSchema();
return { role, grantsSchema };
// Kick off background loading of all supported resource types into DB without awaiting.
this.loadResourcesTask.perform(role.scopeID);
return {
role,
grantsSchema,
loadResourcesTask: this.loadResourcesTask,
searchableResourceTypes: NAMED_RESOURCE_TYPES,
};
}
/**
* Loads all supported resource types recursively so they are available
* for ID autocompletion suggestions.
*/
loadResourcesTask = dropTask(async (scopeId) => {
await Promise.allSettled(
NAMED_RESOURCE_TYPES.map((type) =>
this.store.query(type, { scope_id: scopeId, recursive: true }),
),
);
});
@notifyError(({ message }) => message, { catch: true })
async fetchGrantsSchema() {
const adapter = this.store.adapterFor('application');
const url = `${adapter.host ?? ''}/grants-schema.json`;
@@ -31,6 +31,8 @@
<Form::Role::EditGrants
@model={{@model.role}}
@grantsSchema={{@model.grantsSchema}}
@loadResourcesTask={{@model.loadResourcesTask}}
@searchableResourceTypes={{@model.searchableResourceTypes}}
@submit={{fn this.save @model.role}}
@cancel={{fn this.cancel @model.role}}
/>
+107 -24
View File
@@ -5,6 +5,12 @@
const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields'];
const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}'];
const TEMPLATE_RESOURCE_TYPES = {
'{{.User.Id}}': 'user',
'{{.Account.Id}}': 'account',
'{{user.id}}': 'user',
'{{account.id}}': 'account',
};
const filterByPrefix = (values, partial) =>
values.filter((value) => value.startsWith(partial));
const withWildCard = (values = []) => ['*', ...new Set(values)];
@@ -33,7 +39,7 @@ const parseGrantFields = (lineText) =>
.filter(({ fieldName }) => fieldName);
const normalizeGrantsSchema = (grantsSchema) => {
const resourceTypes = grantsSchema.resource_types ?? [];
const resourceTypes = grantsSchema?.resource_types ?? [];
const resourcesByType = resourceTypes.reduce((resources, resourceType) => {
const collectionActions = resourceType.collection_actions ?? [];
@@ -84,8 +90,13 @@ const normalizeGrantsSchema = (grantsSchema) => {
};
};
const getResourceTypesForId = (schema, id) =>
id.includes('_')
const getResourceTypesForId = (schema, id) => {
const templateType = TEMPLATE_RESOURCE_TYPES[id];
if (templateType) {
return [templateType].filter((type) => schema.resourcesByType[type]);
}
return id.includes('_')
? [
schema.resourceTypesByIdPrefix.find(
({ idPrefix }) => id.split('_')[0] === idPrefix,
@@ -94,6 +105,7 @@ const getResourceTypesForId = (schema, id) =>
: schema.resourceTypesByIdPrefix
.filter(({ idPrefix }) => idPrefix.startsWith(id))
.map(({ type }) => type);
};
const getCompatibleResourceTypeForIds = (schema, idsValue) => {
const resourceTypesById = parseIds(idsValue).flatMap((id) =>
@@ -234,7 +246,38 @@ const getActionOptions = (schema, typeValue, idsValue) => {
: actionOptions;
};
function grantCompletions(context, schema, translatedStrings) {
const getIdLookupTypes = (schema, typeValue, enteredIds) => {
if (enteredIds.length > 0) {
const compatibleResourceType = getCompatibleResourceTypeForIds(
schema,
enteredIds.join(','),
);
// No valid suggestions if there are mixed resource types across entered IDs
return compatibleResourceType ? [compatibleResourceType] : [];
}
if (typeValue === '*') {
// Only parent resource IDs (those that have child types) are useful
return Object.keys(schema.childResourceTypesByParentType);
}
if (typeValue) {
const parentType = schema.resourcesByType[typeValue]?.parentType;
// Restrict to the parent's IDs otherwise there are no valid ID suggestions besides wildcard
return parentType ? [parentType] : [];
}
// All types are potentially valid
return null;
};
async function grantCompletions(
context,
schema,
translate,
idLookup,
getIsLoading,
) {
const line = context.state.doc.lineAt(context.pos);
const beforeCursor = line.text.slice(0, context.pos - line.from);
@@ -281,37 +324,73 @@ function grantCompletions(context, schema, translatedStrings) {
const options = filterByPrefix(typeOptions, partial).map((type) => ({
label: type,
type: 'type',
info: type === '*' ? translatedStrings.wildcardTypes : '',
info: type === '*' ? translate('wildcard-types') : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translatedStrings.noSuggestions)],
: [getNoSuggestionsOption(translate('no-suggestions'))],
};
}
const idsValueMatch = beforeCursor.match(/ids=([a-z0-9_*.{},-]*)$/);
const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/);
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
const staticOptions = filterByPrefix(['*', ...ID_TEMPLATES], partial)
.filter((value) => !hasEnteredIds || value !== '*')
.filter(
(value) =>
(!hasEnteredIds && !typeValue) || !ID_TEMPLATES.includes(value),
)
.filter((value) => !enteredIds.includes(value))
.map((value) => ({
label: value,
type: value === '*' ? 'constant' : 'interface',
info:
value === '*'
? translate('wildcard-ids')
: translate('template-value'),
}));
const compatibleResourceTypes = getIdLookupTypes(
schema,
typeValue,
enteredIds,
);
const ids = await idLookup(partial, compatibleResourceTypes);
const idOptions = ids
.filter(({ id }) => !enteredIds.includes(id))
.map(({ id, name }) => ({
label: id,
detail: name,
type: 'variable',
}));
const allOptions = [...staticOptions, ...idOptions];
// Show a loading placeholder when IDs are still being fetched in the background
if (getIsLoading()) {
allOptions.push({
label: translate('loading-ids'),
type: 'text',
apply: () => {},
});
}
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,
})),
options: allOptions.length
? allOptions
: [getNoSuggestionsOption(translate('no-suggestions'))],
// We do our own filtering via DB search, so disable CodeMirror's
// built-in filtering. Without this, name-based searches are dropped
// because the label (the ID) doesn't match the typed text (the name).
filter: false,
};
}
@@ -321,6 +400,7 @@ function grantCompletions(context, schema, translatedStrings) {
const enteredActions = actionsValueMatch[1].split(',');
const partial = enteredActions.pop() ?? '';
const hasEnteredActions = enteredActions.length > 0;
const options = filterByPrefix(
getActionOptions(schema, typeValue, idsValue),
partial,
@@ -330,14 +410,14 @@ function grantCompletions(context, schema, translatedStrings) {
.map((action) => ({
label: action,
type: 'constant',
info: action === '*' ? translatedStrings.wildcardActions : '',
info: action === '*' ? translate('wildcard-actions') : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translatedStrings.noSuggestions)],
: [getNoSuggestionsOption(translate('no-suggestions'))],
};
}
@@ -357,7 +437,7 @@ function grantCompletions(context, schema, translatedStrings) {
.map((value) => ({
label: value,
type: 'constant',
info: translatedStrings.allFields,
info: translate('all-fields'),
})),
};
}
@@ -367,9 +447,12 @@ function grantCompletions(context, schema, translatedStrings) {
export const createGrantCompletionSource = (
grantsSchema,
translatedStrings,
translate,
idLookup,
getIsLoading,
) => {
const schema = normalizeGrantsSchema(grantsSchema);
return (context) => grantCompletions(context, schema, translatedStrings);
return (context) =>
grantCompletions(context, schema, translate, idLookup, getIsLoading);
};
+1 -1
View File
@@ -30,7 +30,7 @@ const createAddTextAction = (name) => ({
});
const normalizeGrantsSchema = (grantsSchema) => {
const resourceTypes = grantsSchema.resource_types ?? [];
const resourceTypes = grantsSchema?.resource_types ?? [];
const resourcesByType = resourceTypes.reduce((resources, resourceType) => {
const collectionActions = resourceType.collection_actions ?? [];
@@ -38,25 +38,12 @@ module('Unit | Utils | grant-completions', function (hooks) {
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;
this.translate = (key) =>
this.intl.t(`resources.role.edit-grants.completion-info.${key}`);
this.idLookup = async () => [];
this.getIsLoading = () => false;
const response = await fetch('/grants-schema.json');
@@ -65,6 +52,8 @@ module('Unit | Utils | grant-completions', function (hooks) {
}
const grantsSchemaData = await response.json();
this.grantsSchemaData = grantsSchemaData;
allActionLabels = grantsSchemaData.resource_types.reduce(
(labels, resource) => {
const resourceLabels = [
@@ -79,7 +68,9 @@ module('Unit | Utils | grant-completions', function (hooks) {
this.grantCompletionSource = createGrantCompletionSource(
grantsSchemaData,
this.completionTranslatedStrings,
this.translate,
this.idLookup,
this.getIsLoading,
);
});
@@ -130,13 +121,33 @@ module('Unit | Utils | grant-completions', function (hooks) {
lineText: 'ids=hs_123;type=h',
expectedLabels: ['NO_SUGGESTIONS'],
},
'{{.User.Id}} template has no child resource types so shows no suggestions':
{
lineText: 'ids={{.User.Id}};type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'{{.Account.Id}} template has no child resource types so shows no suggestions':
{
lineText: 'ids={{.Account.Id}};type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'{{user.id}} template has no child resource types so shows no suggestions':
{
lineText: 'ids={{user.id}};type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
'{{account.id}} template has no child resource types so shows no suggestions':
{
lineText: 'ids={{account.id}};type=',
expectedLabels: ['NO_SUGGESTIONS'],
},
},
function (assert, { lineText, expectedLabels }) {
const completionResult = this.grantCompletionSource(
async function (assert, { lineText, expectedLabels }) {
const completionResult = await this.grantCompletionSource(
createCompletionContext(lineText),
);
const resolvedLabels = expectedLabels.map((label) =>
label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label,
label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label,
);
assert.deepEqual(getLabels(completionResult), resolvedLabels);
@@ -280,76 +291,439 @@ module('Unit | Utils | grant-completions', function (hooks) {
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'],
},
'{{.User.Id}} template without a type suggests user id actions': {
lineText: 'ids={{.User.Id}};actions=',
expectedLabels: [
'*',
'list-resolvable-aliases',
'read',
'update',
'delete',
'add-accounts',
'set-accounts',
'remove-accounts',
],
},
'{{.Account.Id}} template without a type suggests account id actions': {
lineText: 'ids={{.Account.Id}};actions=',
expectedLabels: [
'*',
'set-password',
'change-password',
'read',
'update',
'delete',
],
},
'{{user.id}} template without a type suggests user id actions': {
lineText: 'ids={{user.id}};actions=',
expectedLabels: [
'*',
'list-resolvable-aliases',
'read',
'update',
'delete',
'add-accounts',
'set-accounts',
'remove-accounts',
],
},
'{{account.id}} template without a type suggests account id actions': {
lineText: 'ids={{account.id}};actions=',
expectedLabels: [
'*',
'set-password',
'change-password',
'read',
'update',
'delete',
],
},
'mixed user and account templates show a no suggestions placeholder': {
lineText: 'ids={{.User.Id}},{{.Account.Id}};actions=',
expectedLabels: ['NO_SUGGESTIONS'],
},
},
function (assert, { lineText, expectedLabels }) {
const completionResult = this.grantCompletionSource(
async function (assert, { lineText, expectedLabels }) {
const completionResult = await this.grantCompletionSource(
createCompletionContext(lineText),
);
const resolvedLabels = (
typeof expectedLabels === 'function' ? expectedLabels() : expectedLabels
).map((label) =>
label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label,
label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : 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(
test('completion info labels are translated values', async function (assert) {
const typeCompletionResult = await this.grantCompletionSource(
createCompletionContext('type='),
);
const idsCompletionResult = this.grantCompletionSource(
const idsCompletionResult = await this.grantCompletionSource(
createCompletionContext('ids='),
);
const outputFieldsCompletionResult = this.grantCompletionSource(
const outputFieldsCompletionResult = await this.grantCompletionSource(
createCompletionContext('output_fields='),
);
const actionsCompletionResult = this.grantCompletionSource(
const actionsCompletionResult = await this.grantCompletionSource(
createCompletionContext('actions='),
);
assert.strictEqual(
typeCompletionResult.options[0].info,
this.completionTranslatedStrings.wildcardTypes,
this.translate('wildcard-types'),
);
assert.deepEqual(getInfos(idsCompletionResult), [
this.completionTranslatedStrings.wildcardIds,
this.completionTranslatedStrings.templateValue,
this.completionTranslatedStrings.templateValue,
this.translate('wildcard-ids'),
this.translate('template-value'),
this.translate('template-value'),
]);
assert.strictEqual(
outputFieldsCompletionResult.options[0].info,
this.completionTranslatedStrings.allFields,
this.translate('all-fields'),
);
assert.strictEqual(
actionsCompletionResult.options[0].info,
this.completionTranslatedStrings.wildcardActions,
this.translate('wildcard-actions'),
);
});
module('id auto completion', function () {
test('idLookup results are included alongside static options', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [
{ id: 'ttcp_abc123', name: 'My Target' },
{ id: 'ttcp_def456', name: 'Another Target' },
],
() => false,
);
const result = await source(createCompletionContext('ids='));
const labels = getLabels(result);
assert.true(labels.includes('*'));
assert.true(labels.includes('{{.User.Id}}'));
assert.true(labels.includes('{{.Account.Id}}'));
assert.true(labels.includes('ttcp_abc123'));
assert.true(labels.includes('ttcp_def456'));
});
test('idLookup results have detail set to the resource name', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [{ id: 'ttcp_abc123', name: 'My Target' }],
() => false,
);
const result = await source(createCompletionContext('ids='));
const idOption = result.options.find((o) => o.label === 'ttcp_abc123');
assert.ok(idOption, 'found the ID option');
assert.strictEqual(idOption.detail, 'My Target');
});
test('static options appear before idLookup results', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [{ id: 'ttcp_abc123', name: 'My Target' }],
() => false,
);
const result = await source(createCompletionContext('ids='));
const labels = getLabels(result);
assert.true(
labels.indexOf('*') < labels.indexOf('ttcp_abc123'),
'wildcard appears before DB results',
);
});
test('loading placeholder is appended when getIsLoading returns true', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [{ id: 'ttcp_abc123', name: 'My Target' }],
() => true,
);
const result = await source(createCompletionContext('ids='));
const labels = getLabels(result);
assert.true(labels.includes(this.translate('loading-ids')));
assert.strictEqual(
labels.at(-1),
this.translate('loading-ids'),
'loading placeholder is the last option',
);
});
test('loading placeholder is NOT present when getIsLoading returns false', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids='));
const labels = getLabels(result);
assert.false(labels.includes(this.translate('loading-ids')));
});
test('wildcard and templates are hidden when any IDs are already entered', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids=ttcp_abc123,'));
const labels = getLabels(result);
assert.false(labels.includes('*'));
assert.false(labels.includes('{{.User.Id}}'));
assert.false(labels.includes('{{.Account.Id}}'));
});
test('templates are hidden when a type field is present, even with no IDs entered', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('type=user;ids='));
const labels = getLabels(result);
assert.false(labels.includes('{{.User.Id}}'));
assert.false(labels.includes('{{.Account.Id}}'));
});
test('wildcard is still suggested when a type field is present and no IDs are entered', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('type=user;ids='));
const labels = getLabels(result);
assert.true(labels.includes('*'));
});
test('templates are suggested when no type field and no IDs are entered', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids='));
const labels = getLabels(result);
assert.true(labels.includes('{{.User.Id}}'));
assert.true(labels.includes('{{.Account.Id}}'));
});
test('when type=* idLookup is called with all parent resource types', async function (assert) {
assert.expect(1);
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async (partial, compatibleResourceTypes) => {
assert.deepEqual(compatibleResourceTypes, [
'auth-method',
'host-catalog',
'credential-store',
]);
return [];
},
() => false,
);
await source(createCompletionContext('type=*;ids='));
});
test('when type is a child type idLookup is called with the parent type and its results are included', async function (assert) {
assert.expect(2);
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async (partial, compatibleResourceTypes) => {
assert.deepEqual(compatibleResourceTypes, ['host-catalog']);
return [{ id: 'hcst_parentid', name: 'My Host Catalog' }];
},
() => false,
);
const result = await source(
createCompletionContext('type=host-set;ids='),
);
assert.true(getLabels(result).includes('hcst_parentid'));
});
test('already-entered IDs are not offered again', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids={{.User.Id}},'));
const labels = getLabels(result);
assert.false(labels.includes('{{.User.Id}}'));
});
test('empty idLookup result still returns static options', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids='));
assert.deepEqual(getLabels(result), [
'*',
'{{.User.Id}}',
'{{.Account.Id}}',
]);
});
test('partial prefix filters static options correctly', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids={{'));
const labels = getLabels(result);
assert.false(labels.includes('*'), 'wildcard filtered out by prefix');
assert.deepEqual(labels, ['{{.User.Id}}', '{{.Account.Id}}']);
});
test('idLookup is called when partial contains uppercase letters and spaces, enabling name-based search', async function (assert) {
assert.expect(2);
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async (partial) => {
assert.strictEqual(partial, 'My Target');
return [{ id: 'ttcp_abc123', name: 'My Target' }];
},
() => false,
);
const result = await source(createCompletionContext('ids=My Target'));
const labels = getLabels(result);
assert.true(labels.includes('ttcp_abc123'));
});
test('idLookup results with undefined name still work (detail is undefined)', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [{ id: 'ttcp_abc123', name: undefined }],
() => false,
);
const result = await source(createCompletionContext('ids='));
const idOption = result.options.find((o) => o.label === 'ttcp_abc123');
assert.ok(idOption);
assert.strictEqual(idOption.detail, undefined);
});
test('idLookup is called with compatibleResourceTypes when entered IDs all share a single resource type', async function (assert) {
assert.expect(1);
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async (partial, compatibleResourceTypes) => {
assert.deepEqual(compatibleResourceTypes, ['target']);
return [];
},
() => false,
);
await source(createCompletionContext('ids=ttcp_abc123,'));
});
test('no suggestions shown when entered IDs have mixed resource types', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(
createCompletionContext('ids=ttcp_abc123,hs_456,'),
);
assert.deepEqual(getLabels(result), [this.translate('no-suggestions')]);
});
test('idLookup results matching already-entered IDs are excluded', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [
{ id: 'ttcp_abc123', name: 'Already Entered' },
{ id: 'ttcp_def456', name: 'New Target' },
],
() => false,
);
const result = await source(createCompletionContext('ids=ttcp_abc123,'));
const labels = getLabels(result);
assert.false(
labels.includes('ttcp_abc123'),
'already-entered ID excluded',
);
assert.true(labels.includes('ttcp_def456'), 'new ID included');
});
test('no suggestions shown when idLookup returns empty results with entered IDs', async function (assert) {
const source = createGrantCompletionSource(
this.grantsSchemaData,
this.translate,
async () => [],
() => false,
);
const result = await source(createCompletionContext('ids=ttcp_abc123,'));
assert.deepEqual(getLabels(result), [this.translate('no-suggestions')]);
});
});
});