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:
1 parent
1d126dc074
commit
e5657a8d4c
11 files changed
+845
-113
No files matched your search
@@ -63,14 +63,35 @@ export default class SqliteHandler {
|
||||
rateLimitWarning;
|
||||
|
||||
if (!peekDb) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
let tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
// The global token key represents a recursive query from global scope,
|
||||
// which pulls back every resource of this type.
|
||||
const globalTokenKey = `${type}-${hashCode({ scope_id: 'global', recursive: true })}`;
|
||||
if (storeToken) {
|
||||
// Always prefer the global token when it exists for
|
||||
// any more fine-grained query. The global query already fetched every
|
||||
// item for this resource type, so we should just re-use it.
|
||||
if (tokenKey !== globalTokenKey) {
|
||||
const [globalTokenObj] = await this.sqlite.fetchResource({
|
||||
...generateSQLExpressions('token', {
|
||||
filters: { id: [{ equals: globalTokenKey }] },
|
||||
}),
|
||||
});
|
||||
if (globalTokenObj?.token) {
|
||||
tokenKey = globalTokenKey;
|
||||
listToken = globalTokenObj.token;
|
||||
}
|
||||
}
|
||||
|
||||
// Only look up the specific token if no global token was found.
|
||||
if (!listToken) {
|
||||
const [tokenObj] = await this.sqlite.fetchResource({
|
||||
...generateSQLExpressions('token', {
|
||||
filters: { id: [{ equals: tokenKey }] },
|
||||
}),
|
||||
});
|
||||
listToken = tokenObj?.token;
|
||||
}
|
||||
} else {
|
||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||
// since we are not storing the token we do not get back a list of removed_ids
|
||||
|
||||
@@ -470,6 +470,62 @@ module('Unit | Handler | sqlite-handler', function (hooks) {
|
||||
assert.strictEqual(results[0].name, 'specific-target');
|
||||
});
|
||||
|
||||
test('it reuses the global token for a more specific query when the global token exists', async function (assert) {
|
||||
const targets = this.server.createList('target', 3, { scope });
|
||||
let capturedListToken;
|
||||
let responseToken = 'specific-list-token';
|
||||
|
||||
this.server.get('targets', (_schema, request) => {
|
||||
capturedListToken = request.queryParams.list_token;
|
||||
return {
|
||||
items: targets.map((t) =>
|
||||
this.server.serializerOrRegistry.serialize(t),
|
||||
),
|
||||
list_token: responseToken,
|
||||
response_type: 'complete',
|
||||
};
|
||||
});
|
||||
|
||||
// Run the specific query first
|
||||
await store.query('target', { scope_id: 'some-specific-scope' });
|
||||
|
||||
// Switch the response token and run the global query
|
||||
responseToken = 'global-list-token';
|
||||
await store.query('target', { scope_id: 'global', recursive: true });
|
||||
|
||||
capturedListToken = undefined;
|
||||
|
||||
// Re-run the specific query which has a cached token but should prefer the global token since it exists
|
||||
await store.query('target', { scope_id: 'some-specific-scope' });
|
||||
|
||||
assert.strictEqual(capturedListToken, 'global-list-token');
|
||||
});
|
||||
|
||||
test('it uses the specific token when no global token exists', async function (assert) {
|
||||
const targets = this.server.createList('target', 3, { scope });
|
||||
let capturedListToken;
|
||||
|
||||
this.server.get('targets', (_schema, request) => {
|
||||
capturedListToken = request.queryParams.list_token;
|
||||
return {
|
||||
items: targets.map((t) =>
|
||||
this.server.serializerOrRegistry.serialize(t),
|
||||
),
|
||||
list_token: 'specific-list-token',
|
||||
response_type: 'complete',
|
||||
};
|
||||
});
|
||||
|
||||
await store.query('target', { scope_id: 'some-specific-scope' });
|
||||
|
||||
capturedListToken = undefined;
|
||||
|
||||
// Re-run the specific query which should just use the normal specific token since no global token exists
|
||||
await store.query('target', { scope_id: 'some-specific-scope' });
|
||||
|
||||
assert.strictEqual(capturedListToken, 'specific-list-token');
|
||||
});
|
||||
|
||||
test('it retries query when invalid list token error occurs', async function (assert) {
|
||||
let targets = this.server.createList('target', 5, { scope });
|
||||
|
||||
|
||||
@@ -663,13 +663,14 @@ 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:
|
||||
no-suggestions: No suggestions
|
||||
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
|
||||
loading-ids: Still loading ID suggestions…
|
||||
linting-errors:
|
||||
general:
|
||||
whitespace-not-allowed: Whitespace is not allowed
|
||||
|
||||
@@ -191,6 +191,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides for CodeMirror 6 autocomplete and lint tooltips/panels.
|
||||
@mixin dark-mode-codemirror-override {
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.cm-editor {
|
||||
// Autocomplete dropdown and lint hover tooltips
|
||||
.cm-tooltip {
|
||||
background-color: var(--token-color-palette-neutral-50);
|
||||
border: 1px solid var(--token-color-palette-neutral-100);
|
||||
color: var(--token-color-palette-neutral-400);
|
||||
}
|
||||
|
||||
// Arrow pointer for tooltips shown above the cursor
|
||||
.cm-tooltip-above::before {
|
||||
border-top-color: var(--token-color-palette-neutral-50);
|
||||
}
|
||||
|
||||
// Arrow pointer for tooltips shown below the cursor
|
||||
.cm-tooltip-below::before {
|
||||
border-bottom-color: var(--token-color-palette-neutral-50);
|
||||
}
|
||||
|
||||
// Autocomplete list > selected item highlight
|
||||
.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
background-color: var(--token-color-palette-blue-300);
|
||||
color: var(--token-color-palette-neutral-700);
|
||||
}
|
||||
|
||||
// Content/gutter border
|
||||
.cm-content {
|
||||
border-left: 1px solid var(--token-color-palette-neutral-100);
|
||||
}
|
||||
|
||||
// Lint diagnostics panel (shown at the bottom of the editor)
|
||||
.cm-panel.cm-panel-lint {
|
||||
border-top: 3px solid var(--token-color-palette-neutral-100);
|
||||
|
||||
ul {
|
||||
background-color: var(--token-color-palette-neutral-100);
|
||||
|
||||
// Lint panel > selected diagnostic item highlight
|
||||
&:focus li.cm-diagnostic[aria-selected] {
|
||||
background-color: var(--token-color-palette-neutral-50);
|
||||
color: var(--token-color-palette-neutral-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* stylelint-enable selector-class-pattern */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ember-application:not(.rose-theme-light) {
|
||||
@include dark-mode;
|
||||
@@ -198,6 +248,7 @@
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
@include dark-mode-app-header-override;
|
||||
@include dark-mode-app-side-nav-override;
|
||||
@include dark-mode-codemirror-override;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,4 +258,5 @@
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
@include dark-mode-app-header-override;
|
||||
@include dark-mode-app-side-nav-override;
|
||||
@include dark-mode-codemirror-override;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
return { role, grantsSchema };
|
||||
}
|
||||
/**
|
||||
* 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}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
return {
|
||||
from: context.pos - partial.length,
|
||||
options: filterByPrefix(['*', ...ID_TEMPLATES], partial)
|
||||
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 === '*'
|
||||
? translatedStrings.wildcardIds
|
||||
: translatedStrings.templateValue,
|
||||
})),
|
||||
? 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: 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);
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
function (assert, { lineText, expectedLabels }) {
|
||||
const completionResult = this.grantCompletionSource(
|
||||
'{{.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'],
|
||||
},
|
||||
},
|
||||
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':
|
||||
{
|
||||
'{{.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')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user