Introduction
This topic is an addendum to Building an enterprise-grade Jira bulk operations Forge app. Its purpose is to share interesting technical aspects and implementation patterns from the app.
Step-Based Workflow State Management with Cascade Logic
Interesting Aspect: The app implements a workflow state management system where changing one step automatically invalidates downstream steps, ensuring data consistency across complex multi-step processes.
Key Implementation: Automatic cascade invalidation when upstream steps change:
// When a step becomes incomplete, all downstream steps become incomplete
if (completionState !== 'complete') {
const downstreamSteps = this.stepSequence.slice(this.stepSequence.indexOf(stepName) + 1);
for (const downstreamStep of downstreamSteps) {
if (this.stepNamesToCompletionStates[downstreamStep] !== 'complete') {
this.stepNamesToCompletionStates[downstreamStep] = 'incomplete';
changedStepNames.push(downstreamStep);
}
}
}
Location: static/spa/src/model/BulkOpsModel.ts
Reverse Order Notification: Ensures logical consistency by notifying listeners in reverse order:
// Notify in the reverse order since this makes logical sense
for (let i = changedStepNames.length - 1; i >= 0; i--) {
const changedStepName = changedStepNames[i];
const stepCompletionState = this.stepNamesToCompletionStates[changedStepName];
this.notifyStepCompletionStateChangeListeners(changedStepName, stepCompletionState);
}
Location: static/spa/src/model/BulkOpsModel.ts
Configurable Business Rules Engine
Interesting Aspect: The app separates business logic from UI components through a configurable rules engine, making it easy to customize organizational policies without touching core application code.
Static Rules Configuration: Centralized business rules that can be easily modified:
export const allowBulkMovesAcrossProjectCategories = true;
export const allowBulkMovesFromMultipleProjects = false;
export const maximumNumberOfIssuesToBulkActOn = 100;
export const enablePanelExpansion = true;
Location: static/spa/src/extension/bulkOperationStaticRules.ts
Dynamic Rule Enforcement: Rules are applied dynamically during operations:
public augmentJqlWithBusinessRules = async (jql: string, bulkOperationMode: BulkOperationMode): Promise<string> => {
let augmentedJql = jql;
if (!enableTheAbilityToBulkChangeResolvedIssues) {
augmentedJql = `statusCategory != Done and (${augmentedJql})`;
}
if (excludedIssueStatuses.length > 0) {
const statusExclusions = excludedIssueStatuses.map(status => `status != "${status}"`).join(' and ');
augmentedJql = `${statusExclusions} and (${augmentedJql})`;
}
return augmentedJql;
}
Location: static/spa/src/extension/bulkOperationRuleEnforcer.ts
Debounced Observer Pattern for Performance
Interesting Aspect: The app uses a custom observer pattern with debouncing to prevent excessive re-renders while maintaining responsive UI updates.
Debounced Notifications: Prevents performance work item from rapid state changes:
const modelUpdateNotifierDebouncePeriodMilliseconds = 100;
this.debouncedNotifyModelUpdateChangeListeners = debounce(
this.notifyModelUpdateChangeListeners,
modelUpdateNotifierDebouncePeriodMilliseconds,
immediatelyNotifyModelUpdateListeners
);
Location: static/spa/src/model/BulkOpsModel.ts
Custom Listener Management: UUID-based listener tracking with automatic cleanup:
registerListener = (listener: any) => {
if (typeof listener === 'function') {
listener.listenerUuid = uuid();
this.listenerIdsToListeners[listener.listenerUuid] = listener;
const listenerCount = this.getListenerCount();
if (listenerCount > this.maxListenersCount) {
this.maxListenersCount = listenerCount;
}
}
};
Location: static/spa/src/model/ListenerGroup.ts
Expandable Panel System with Accessibility
Interesting Aspect: A reusable panel expansion system that transforms grid-based layouts into full-screen overlays while maintaining accessibility and keyboard navigation.
Configurable Expansion: Feature can be enabled/disabled via configuration:
const renderExpandablePanel = (stepName: StepName, stepNumber: number, label: string, children: React.ReactNode) => {
const isExpanded = enablePanelExpansion && expandedPanel === stepName;
if (enablePanelExpansion && isExpanded) {
return (
<div className="panel-overlay" onClick={handleOverlayClick}>
<div className="panel-expanded">{panelContent}</div>
</div>
);
}
return <div className="padding-panel">{panelContent}</div>;
}
Location: static/spa/src/panel/BulkOperationPanel.tsx
Keyboard Accessibility: ESC key support and body scroll prevention:
useEffect(() => {
if (!enablePanelExpansion) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (expandedPanel && e.key === 'Escape') {
setExpandedPanel(null);
}
};
if (expandedPanel) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [expandedPanel]);
Location: static/spa/src/panel/BulkOperationPanel.tsx
Temporary Permission Elevation Pattern
Interesting Aspect: The app implements a pattern for temporarily elevating user permissions during bulk operations, ensuring users have the necessary access while maintaining security boundaries. The removal of users from the group occurs in a finally block which guarantees it will be call unless the process hangs or is terminated prematurely. Since the permission cleanup is not fully guaranteed, monitoring of users in the custom group used for permission elevation is required.
Temporary Group Membership: Users are temporarily added to a bulk operations group:
export const initiateBulkMove = async (accountId: string, bulkIssueMoveRequestData: any): Promise<InvocationResult<TaskRequestOutcome>> => {
const bulkOpsAppGroupId = getBulkOpsAppGroupId();
const serverInfoResult = await getServerInfo(api.asUser());
if (serverInfoResult.ok) {
const siteUrl = removeTrailingSlashIfExistent(serverInfoResult.data.baseUrl);
await addUserToGroup(siteUrl, accountId, bulkOpsAppGroupId);
try {
result = await doBulkMove(bulkIssueMoveRequestData, 0, createTraceId());
} finally {
await removeUserFromGroup(siteUrl, accountId, bulkOpsAppGroupId);
}
}
}
Location: src/initiateBulkOperations.ts
Exponential Backoff Retry Logic
Interesting Aspect: Robust error handling with exponential backoff for API calls, accounting for the eventually consistent nature of the API to add the user to the group.
Retry with Exponential Backoff: Handles transient failures gracefully:
const doBulkMove = async (bulkIssueMoveRequestData: any, retryNumber: number, traceId: string): Promise<InvocationResult<TaskRequestOutcome>> => {
const response = await api.asUser().requestJira(route`/rest/api/3/bulk/issues/move`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(bulkIssueMoveRequestData)
});
if (!invocationResult.ok && retryNumber < maxBulkOperationRetries) {
const retryDelay = computeRetryDelay(retryNumber);
await delay(retryDelay);
return await doBulkMove(bulkIssueMoveRequestData, retryNumber + 1, traceId);
}
}
Location: src/initiateBulkOperations.ts
Delay Calculation: Exponential backoff with maximum delay cap. The delays and maximum number of retries are tuned to maximise the use of, but not exceed Forge’s 25 runtime invocation timeout.
const maxBulkOperationRetries = 3;
const initialRetryDelay = 2000;
const computeRetryDelay = (retryNumber: number): number => {
// Exponential backoff with a maximum delay of 30 seconds
const maxDelay = 30000;
const delay = initialRetryDelay * Math.pow(2, retryNumber);
return Math.min(delay, maxDelay);
}
Location: src/initiateBulkOperations.ts
Type-Safe Workflow Definition System
Interesting Aspect: The app uses TypeScript’s type system to ensure workflow integrity, preventing runtime errors from invalid step sequences or missing step handlers.
Typed Step Sequences: Different workflows have strongly typed step definitions:
export type MoveStepName = 'filter' | 'issue-selection' | 'target-project-selection' | 'issue-type-mapping' | 'field-mapping' | 'move-or-edit';
export type EditStepName = 'filter' | 'issue-selection' | 'edit-fields' | 'move-or-edit';
export type ImportStepName = 'file-upload' | 'project-and-issue-type-selection' | 'column-mapping' | 'import-issues';
export type StepName = MoveStepName | EditStepName | ImportStepName;
Location: static/spa/src/model/BulkOperationsWorkflow.ts
Generic Model with Type Constraints: Ensures type safety across different workflow types:
export class BulkOpsModel<StepNameSubtype extends StepName> {
private stepSequence: StepNameSubtype[];
private stepNamesToCompletionStates: Record<StepNameSubtype, CompletionState>;
constructor(modelName: string, stepSequence: StepNameSubtype[]) {
this.stepSequence = stepSequence;
this.stepNamesToCompletionStates = {} as Record<StepNameSubtype, CompletionState>;
stepSequence.forEach((stepName) => {
this.stepNamesToCompletionStates[stepName] = 'incomplete';
});
}
}
Location: static/spa/src/model/BulkOpsModel.ts
CSV Import with Smart Column Mapping
Interesting Aspect: Intelligent CSV parsing that automatically suggests field mappings based on column names and data types, with support for complex Jira field types including ADF (Atlassian Document Format).
Smart Type Detection: Automatically detects column data types including rich text:
private interpretColumnType = (columnValue: string): ImportColumnValueType => {
if (!isNaN(Number(columnValue))) return 'number';
if (columnValue.toLowerCase() === 'true' || columnValue.toLowerCase() === 'false') return 'boolean';
try {
const parsedValue = JSON.parse(columnValue) as any;
if (typeof parsedValue === 'object') {
const isAdf = parsedValue.type === 'doc' &&
typeof parsedValue.version === 'number' &&
parsedValue.content && Array.isArray(parsedValue.content);
return isAdf ? 'rich text' : 'unknown';
}
} catch (error) {
return 'string';
}
}
Location: static/spa/src/model/importModel.ts
Automatic Column Mapping: Suggests field mappings based on column names:
const updateSelectionState = async (fieldKeysToMatchInfos: ObjectMapping<ImportColumnMatchInfo>): Promise<void> => {
let newAllMandatoryFieldsHaveColumnMappings = false;
if (props.createIssueMetadata && props.selectedIssueType) {
const issueTypeCreateMetadata = props.createIssueMetadata.issuetypes.find(issueType => {
return issueType.id === props.selectedIssueType.id;
});
newAllMandatoryFieldsHaveColumnMappings = doAllMandatoryFieldsHaveColumnMappings(
issueTypeCreateMetadata, fieldKeysToMatchInfos);
}
importModel.setAllMandatoryFieldsHaveColumnMappings(newAllMandatoryFieldsHaveColumnMappings);
}