Files
threshold-ui/ui/admin/app/utils/grant-completions.js
T
Lisbet Alvarez 7cddf7cb91 Refactor utils to share components (#3279)
* refactor: 💡 initial refactor for utils to share components

* refactor: 💡 actions logic

* Add missing copyright headers

* refactor: 💡 simplify actions getter logic

* refactor: 💡 convert output fields logic into a separate getter

* test: 💍 fix unit test

* fix: 🐛 bug with unknown fields lint error

* refactor: 💡 pr feedback - simplify template check

* refactor: 💡 remove unnecessary unit test file
2026-05-29 16:52:00 -07:00

395 lines
12 KiB
JavaScript

/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import {
GRANT_FIELDS,
TEMPLATE_RESOURCE_TYPES,
normalizeGrantsSchema,
parseGrantLine,
getValidActions,
getValidOutputFields,
} from './grant-parser';
const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}'];
const CRUDL_ACTIONS = new Set(['create', 'read', 'update', 'delete', 'list']);
const filterByPrefix = (values, partial) =>
values.filter((value) => value.startsWith(partial));
const parseIds = (idsValue = '') => idsValue.split(',').filter(Boolean);
const withWildCard = (values = []) => ['*', ...new Set(values)];
const getNoSuggestionsOption = (noSuggestionsLabel) => ({
label: noSuggestionsLabel,
type: 'text',
apply: () => {},
});
const parseGrantLineForCompletions = (lineText = '') => {
const { fields, fieldMap } = parseGrantLine(lineText);
return {
parsedFieldNames: fields.map(({ key }) => key),
idsValue: fieldMap['ids'],
typeValue: fieldMap['type'],
actionsValue: fieldMap['actions'],
outputFieldsValue: fieldMap['output_fields'],
};
};
const getResourceTypesForId = (schema, id) => {
const templateType = TEMPLATE_RESOURCE_TYPES[id];
if (templateType) {
return [templateType].filter((type) => schema.resourcesByType[type]);
}
if (id.includes('_')) {
const prefix = id.split('_')[0];
const type = schema.resourceTypesByIdPrefix[prefix];
return type ? [type] : [];
}
return Object.entries(schema.resourceTypesByIdPrefix)
.filter(([prefix]) => prefix.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 getTypeOptions = (schema, idsValue, compatibleType) => {
// If there is no ids value, we can only suggest top-level resource types that have collection actions
if (!idsValue) {
return schema.topLevelTypes;
}
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
if (hasSpecificIds) {
if (!compatibleType) {
return [];
}
return schema.childResourceTypesByParentType[compatibleType] ?? [];
}
// Return the ones that have id based actions (currently only billing doesn't have them)
return Object.entries(schema.resourcesByType)
.filter(([, resource]) => resource.idActions.length > 0)
.map(([type]) => type);
};
export const analyzeGrantString = (grantsSchema, grantString = '') => {
const schema = normalizeGrantsSchema(grantsSchema);
const { idsValue, typeValue } = parseGrantLineForCompletions(grantString);
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
const hasExplicitType = Boolean(typeValue) && typeValue !== '*';
const hasTemplateIds =
hasSpecificIds &&
parseIds(idsValue).some((id) => id.startsWith('{{') && id.endsWith('}}'));
const hasLiteralIds = hasSpecificIds && !hasTemplateIds;
const compatibleIdsResourceType = hasLiteralIds
? getCompatibleResourceTypeForIds(schema, idsValue)
: null;
const hasInvalidType = hasExplicitType && !schema.resourcesByType[typeValue];
const hasInvalidIds = hasLiteralIds && !compatibleIdsResourceType;
const hasInvalidPinnedIdTypeCombination =
hasSpecificIds &&
hasExplicitType &&
!hasInvalidIds &&
!hasInvalidType &&
!(
schema.childResourceTypesByParentType[compatibleIdsResourceType] ?? []
).includes(typeValue);
let detectedResourceType = null;
if (typeValue && typeValue !== '*') {
// Explicit type specified, use it directly
detectedResourceType = typeValue;
} else if (compatibleIdsResourceType && typeValue === '*') {
// Pinned IDs with wildcard type, return the child resource types
const childTypes =
schema.childResourceTypesByParentType[compatibleIdsResourceType] ?? [];
detectedResourceType = childTypes.length ? childTypes : null;
} else if (typeValue !== '*' && idsValue) {
// No type field, infer resource type from the ID prefix
detectedResourceType = compatibleIdsResourceType;
}
const hasWildcardIds = idsValue === '*';
const crudlOnly = hasWildcardIds && !hasExplicitType;
const actionOptions =
idsValue || typeValue
? getValidActions(schema, {
typeValue,
idsValue,
idsWildcard: hasWildcardIds,
idsKnownType: compatibleIdsResourceType,
}).actions
: [];
const actions =
actionOptions
.filter(
(action) => action !== '*' && (!crudlOnly || CRUDL_ACTIONS.has(action)),
)
.sort((left, right) => left.localeCompare(right)) ?? [];
return {
actions,
detectedResourceType,
hasInvalidType,
hasInvalidIds,
hasInvalidPinnedIdTypeCombination,
};
};
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);
const { parsedFieldNames, idsValue, typeValue } =
parseGrantLineForCompletions(line.text);
const compatibleType = idsValue
? getCompatibleResourceTypeForIds(schema, idsValue)
: null;
// 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,
compatibleType,
);
const typeOptions =
partial === '' && matchingTypeOptions.length
? withWildCard(matchingTypeOptions)
: matchingTypeOptions;
const options = filterByPrefix(typeOptions, partial).map((type) => ({
label: type,
type: 'type',
info: type === '*' ? translate('wildcard-types') : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translate('no-suggestions'))],
};
}
const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/);
if (idsValueMatch) {
const enteredIds = idsValueMatch[1].split(',');
const partial = enteredIds.pop() ?? '';
const hasEnteredIds = enteredIds.length > 0;
const hasEnteredTemplateIds = enteredIds.some((id) => id.startsWith('{{'));
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 from = context.pos - partial.length;
const ids = hasEnteredTemplateIds
? []
: 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,
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,
};
}
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 actionOptions = getValidActions(schema, {
typeValue,
idsValue,
idsWildcard: idsValue?.includes('*'),
idsKnownType: compatibleType,
}).actions;
const options = filterByPrefix(actionOptions, partial)
.filter((action) => !hasEnteredActions || action !== '*')
.filter((action) => !enteredActions.includes(action))
.map((action) => ({
label: action,
type: 'constant',
info: action === '*' ? translate('wildcard-actions') : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translate('no-suggestions'))],
};
}
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;
const { outputFields } = getValidOutputFields(schema, {
typeValue,
idsKnownType: compatibleType,
});
const options = filterByPrefix(outputFields, partial)
.filter((value) => !hasEnteredOutputFields || value !== '*')
.filter((value) => !enteredOutputFields.includes(value))
.map((value) => ({
label: value,
type: 'constant',
info: value === '*' ? translate('all-fields') : '',
}));
return {
from: context.pos - partial.length,
options: options.length
? options
: [getNoSuggestionsOption(translate('no-suggestions'))],
};
}
return null;
}
export const createGrantCompletionSource = (
grantsSchema,
translate,
idLookup,
getIsLoading,
) => {
const schema = normalizeGrantsSchema(grantsSchema);
return (context) =>
grantCompletions(context, schema, translate, idLookup, getIsLoading);
};