@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
218 lines (192 loc) • 8.7 kB
text/typescript
import { CustomAction, CustomActionsPosition, CustomActionTarget } from '../types';
import { arrayIncludesString } from '../utils';
import sortBy from 'lodash/sortBy';
import { CUSTOM_ACTIONS_ERROR_MESSAGE } from '../errors';
export interface CustomActionsValidationResult {
actions: CustomAction[];
errors: string[];
}
type CustomActionValidation = {
isValid: boolean;
errors: string[];
};
/**
* Configuration for custom action validation rules.
* Defines allowed positions, metadata IDs, data model IDs, and fields for each target
* type.
*
*/
const customActionValidationConfig: Record<CustomActionTarget, {
positions: string[];
allowedMetadataIds: string[];
allowedDataModelIds: string[];
allowedFields: string[];
}> = {
[CustomActionTarget.LIVEBOARD]: {
positions: [CustomActionsPosition.PRIMARY, CustomActionsPosition.MENU],
allowedMetadataIds: ['liveboardIds'],
allowedDataModelIds: [],
allowedFields: ['name', 'id', 'position', 'target', 'metadataIds', 'orgIds', 'groupIds'],
},
[CustomActionTarget.VIZ]: {
positions: [CustomActionsPosition.MENU, CustomActionsPosition.PRIMARY, CustomActionsPosition.CONTEXTMENU],
allowedMetadataIds: ['liveboardIds', 'vizIds', 'answerIds'],
allowedDataModelIds: ['modelIds', 'modelColumnNames'],
allowedFields: ['name', 'id', 'position', 'target', 'metadataIds', 'orgIds', 'groupIds', 'dataModelIds'],
},
[CustomActionTarget.ANSWER]: {
positions: [CustomActionsPosition.MENU, CustomActionsPosition.PRIMARY, CustomActionsPosition.CONTEXTMENU],
allowedMetadataIds: ['answerIds'],
allowedDataModelIds: ['modelIds', 'modelColumnNames'],
allowedFields: ['name', 'id', 'position', 'target', 'metadataIds', 'orgIds', 'groupIds', 'dataModelIds'],
},
[CustomActionTarget.SPOTTER]: {
positions: [CustomActionsPosition.MENU, CustomActionsPosition.CONTEXTMENU],
allowedMetadataIds: [],
allowedDataModelIds: ['modelIds'],
allowedFields: ['name', 'id', 'position', 'target', 'orgIds', 'groupIds', 'dataModelIds'],
},
};
/**
* Validates a single custom action based on its target type
* @param action - The custom action to validate
* @param primaryActionsPerTarget - Map to track primary actions per target
* @returns CustomActionValidation with isValid flag and reason string
*
* @hidden
*/
const validateCustomAction = (action: CustomAction, primaryActionsPerTarget: Map<CustomActionTarget, CustomAction>): CustomActionValidation => {
const { id: actionId, target: targetType, position, metadataIds, dataModelIds } = action;
// Check if target type is supported
if (!customActionValidationConfig[targetType]) {
const errorMessage = CUSTOM_ACTIONS_ERROR_MESSAGE.UNSUPPORTED_TARGET(actionId, targetType);
return { isValid: false, errors: [errorMessage] };
}
const config = customActionValidationConfig[targetType];
const errors: string[] = [];
// Validate position
if (!arrayIncludesString(config.positions, position)) {
const supportedPositions = config.positions.join(', ');
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.INVALID_POSITION(position, targetType, supportedPositions));
}
// Validate metadata IDs
if (metadataIds) {
const invalidMetadataIds = Object.keys(metadataIds).filter(
(key) => !arrayIncludesString(config.allowedMetadataIds, key)
);
if (invalidMetadataIds.length > 0) {
const supportedMetadataIds = config.allowedMetadataIds.length > 0 ? config.allowedMetadataIds.join(', ') : 'none';
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.INVALID_METADATA_IDS(targetType, invalidMetadataIds, supportedMetadataIds));
}
}
// Validate data model IDs
if (dataModelIds) {
const invalidDataModelIds = Object.keys(dataModelIds).filter(
(key) => !arrayIncludesString(config.allowedDataModelIds, key)
);
if (invalidDataModelIds.length > 0) {
const supportedDataModelIds = config.allowedDataModelIds.length > 0 ? config.allowedDataModelIds.join(', ') : 'none';
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.INVALID_DATA_MODEL_IDS(targetType, invalidDataModelIds, supportedDataModelIds));
}
}
// Validate allowed fields
const actionKeys = Object.keys(action);
const invalidFields = actionKeys.filter((key) => !arrayIncludesString(config.allowedFields, key));
if (invalidFields.length > 0) {
const supportedFields = config.allowedFields.join(', ');
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.INVALID_FIELDS(targetType, invalidFields, supportedFields));
}
return {
isValid: errors.length === 0,
errors,
};
};
/**
* Validates basic action structure and required fields
* @param action - The action to validate
* @returns Object containing validation result and missing fields
*
* @hidden
*/
const validateActionStructure = (action: any): { isValid: boolean; missingFields: string[] } => {
if (!action || typeof action !== 'object') {
return { isValid: false, missingFields: [] };
}
// Check for all missing required fields
const missingFields = ['id', 'name', 'target', 'position'].filter(field => !action[field]);
return { isValid: missingFields.length === 0, missingFields };
};
/**
* Checks for duplicate IDs among actions
* @param actions - Array of actions to check
* @returns Object containing filtered actions and duplicate errors
*
* @hidden
*/
const filterDuplicateIds = (actions: CustomAction[]): { actions: CustomAction[]; errors: string[] } => {
const idMap = actions.reduce((map, action) => {
const list = map.get(action.id) || [];
list.push(action);
map.set(action.id, list);
return map;
}, new Map<string, CustomAction[]>());
const { actions: actionsWithUniqueIds, errors } = Array.from(idMap.entries()).reduce(
(acc, [id, actionsWithSameId]) => {
if (actionsWithSameId.length === 1) {
acc.actions.push(actionsWithSameId[0]);
} else {
// Keep the first action and add error for duplicates
acc.actions.push(actionsWithSameId[0]);
const duplicateNames = actionsWithSameId.slice(1).map(action => action.name);
acc.errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.DUPLICATE_IDS(id, duplicateNames, actionsWithSameId[0].name));
}
return acc;
},
{ actions: [] as CustomAction[], errors: [] as string[] }
);
return { actions: actionsWithUniqueIds, errors };
};
/**
* Validates and processes custom actions
* @param customActions - Array of custom actions to validate
* @returns Object containing valid actions and any validation errors
*/
export const getCustomActions = (customActions: CustomAction[]): CustomActionsValidationResult => {
const errors: string[] = [];
const primaryActionsPerTarget = new Map<CustomActionTarget, CustomAction>();
if (!customActions || !Array.isArray(customActions)) {
return { actions: [], errors: [] };
}
// Step 1: Handle invalid actions first (null, undefined, missing required
// fields)
const validActions = customActions.filter(action => {
const validation = validateActionStructure(action);
if (!validation.isValid) {
if (!action || typeof action !== 'object') {
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.INVALID_ACTION_OBJECT);
} else {
errors.push(CUSTOM_ACTIONS_ERROR_MESSAGE.MISSING_REQUIRED_FIELDS((action as any).id, validation.missingFields));
}
return false;
}
return true;
});
// Step 2: Check for duplicate IDs among valid actions
const { actions: actionsWithUniqueIds, errors: duplicateErrors } = filterDuplicateIds(validActions);
// Add duplicate errors to the errors array
duplicateErrors.forEach(error => errors.push(error));
// Step 3: Validate actions with unique IDs
const finalValidActions: CustomAction[] = [];
actionsWithUniqueIds.forEach((action) => {
const { isValid, errors: validationErrors } = validateCustomAction(action, primaryActionsPerTarget);
validationErrors.forEach(error => errors.push(error));
if (isValid) {
finalValidActions.push(action);
}
});
const sortedActions = sortBy(finalValidActions, (a) => a.name.toLocaleLowerCase());
return {
actions: sortedActions,
errors: errors,
};
};