From 41be8dfb77486b1fbb2f2a28183692c2fc3ee155 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 23 Jul 2024 13:10:29 -0600 Subject: [PATCH] feat: more flexibility --- src/commands/org/create/scratch.ts | 7 +- src/commands/org/resume/scratch.ts | 7 +- src/components/design-elements.ts | 1 + src/components/stages.tsx | 315 +++++++++++++++++------------ 4 files changed, 198 insertions(+), 132 deletions(-) diff --git a/src/commands/org/create/scratch.ts b/src/commands/org/create/scratch.ts index 7bfb12b5..e62e9c76 100644 --- a/src/commands/org/create/scratch.ts +++ b/src/commands/org/create/scratch.ts @@ -176,29 +176,32 @@ export default class OrgCreateScratch extends SfCommand { title: flags.async ? 'Creating Scratch Org (async)' : 'Creating Scratch Org', jsonEnabled: this.jsonEnabled(), data: { alias: flags.alias }, - info: [ + postInfoBlock: [ { label: 'Request Id', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.Id && terminalLink(data.scratchOrgInfo.Id, `${baseUrl}/${data.scratchOrgInfo.Id}`), bold: true, }, { label: 'OrgId', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.ScratchOrg, bold: true, color: 'cyan', }, { label: 'Username', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.SignupUsername, bold: true, color: 'cyan', }, { label: 'Alias', + type: 'static-key-value', get: (data) => data?.alias, - static: true, }, ], }); diff --git a/src/commands/org/resume/scratch.ts b/src/commands/org/resume/scratch.ts index a6cc57c7..cda1dedb 100644 --- a/src/commands/org/resume/scratch.ts +++ b/src/commands/org/resume/scratch.ts @@ -65,29 +65,32 @@ export default class OrgResumeScratch extends SfCommand { title: 'Resuming Scratch Org', jsonEnabled: this.jsonEnabled(), data: { alias: cached?.alias }, - info: [ + postInfoBlock: [ { label: 'Request Id', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.Id && terminalLink(data.scratchOrgInfo.Id, `${hubBaseUrl}/${data.scratchOrgInfo.Id}`), bold: true, }, { label: 'OrgId', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.ScratchOrg, bold: true, color: 'cyan', }, { label: 'Username', + type: 'dynamic-key-value', get: (data) => data?.scratchOrgInfo?.SignupUsername, bold: true, color: 'cyan', }, { label: 'Alias', + type: 'static-key-value', get: (data) => data?.alias, - static: true, }, ], }); diff --git a/src/components/design-elements.ts b/src/components/design-elements.ts index 265d4b04..86e71eed 100644 --- a/src/components/design-elements.ts +++ b/src/components/design-elements.ts @@ -12,6 +12,7 @@ export const icons = { skipped: figures.circle, completed: figures.tick, failed: figures.cross, + current: figures.play, }; export const spinners: Record = { diff --git a/src/components/stages.tsx b/src/components/stages.tsx index 21371864..4b11beb5 100644 --- a/src/components/stages.tsx +++ b/src/components/stages.tsx @@ -25,6 +25,12 @@ const isInCi = ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); type Info> = { + /** + * key-value: Display a key-value pair with a spinner. + * static-key-value: Display a key-value pair without a spinner. + * message: Display a message. + */ + type: 'dynamic-key-value' | 'static-key-value' | 'message'; /** * Color of the value. */ @@ -36,33 +42,40 @@ type Info> = { * @param data The data property on the MultiStageComponent. * @returns {string | undefined} */ - get?: (data?: T) => string | undefined; + get: (data?: T) => string | undefined; /** * Whether the value should be bold. */ bold?: boolean; +}; + +type KeyValuePair> = Info & { /** - * Whether the value should be a static key-value pair (not a spinner component). - */ - static?: boolean; - /** - * Label to display next to the value. + * Label of the key-value pair. */ label: string; - /** - * Stage to display the value on. If not provided, the value will be displayed at the bottom of the stages component. - */ - stage?: string; + type: 'dynamic-key-value' | 'static-key-value'; +}; + +type SimpleMessage> = Info & { + type: 'message'; }; -type FormattedInfo = { +type InfoBlock> = Array | SimpleMessage>; +type StageInfoBlock> = Array< + (KeyValuePair & { stage: string }) | (SimpleMessage & { stage: string }) +>; + +type FormattedKeyValue = { readonly color?: string; readonly isBold?: boolean; - readonly isStatic?: boolean; - readonly label: string; + // eslint-disable-next-line react/no-unused-prop-types + readonly label?: string; readonly value: string | undefined; // eslint-disable-next-line react/no-unused-prop-types readonly stage?: string; + // eslint-disable-next-line react/no-unused-prop-types + readonly type: 'dynamic-key-value' | 'static-key-value' | 'message'; }; type MultiStageComponentOptions> = { @@ -77,13 +90,11 @@ type MultiStageComponentOptions> = { /** * Information to display at the bottom of the stages component. */ - readonly info?: Array>; + readonly postInfoBlock?: Array | SimpleMessage>; /** - * Messages to display at the bottom of the stages component. - * - * This will be rendered between the stages and the info section. + * Information to display below the title but above the stages. */ - readonly messages?: string[]; + readonly preInfoBlock?: Array | SimpleMessage>; /** * Whether to show the total elapsed time. Defaults to true */ @@ -92,6 +103,10 @@ type MultiStageComponentOptions> = { * Whether to show the time spent on each stage. Defaults to true */ readonly showStageTime?: boolean; + /** + * Information to display for a specific stage. Each object must have a stage property set. + */ + readonly stageInfoBlock?: StageInfoBlock; /** * The unit to use for the timer. Defaults to 'ms' */ @@ -110,8 +125,9 @@ type MultiStageComponentOptions> = { type StagesProps = { readonly error?: Error | undefined; - readonly info?: FormattedInfo[]; - readonly messages?: string[]; + readonly postInfoBlock?: FormattedKeyValue[]; + readonly preInfoBlock?: FormattedKeyValue[]; + readonly stageInfoBlock?: FormattedKeyValue[]; readonly title: string; readonly hasElapsedTime?: boolean; readonly hasStageTime?: boolean; @@ -119,41 +135,67 @@ type StagesProps = { readonly stageTracker: StageTracker; }; -function StaticKeyValue({ label, value, isBold, color, isStatic }: FormattedInfo): React.ReactNode { - if (!value || !isStatic) return; +function StaticKeyValue({ label, value, isBold, color }: FormattedKeyValue): React.ReactNode { + if (!value) return; return ( - + {label}: {value} ); } -function Infos({ info, error, stage }: { info: FormattedInfo[]; error?: Error; stage?: string }): React.ReactNode { +function SimpleMessage({ value, color, isBold }: FormattedKeyValue): React.ReactNode { + if (!value) return; return ( - info + + {value} + + ); +} + +function Infos({ + keyValuePairs, + error, + stage, +}: { + keyValuePairs: FormattedKeyValue[]; + error?: Error; + stage?: string; +}): React.ReactNode { + return ( + keyValuePairs // If stage is provided, only show info for that stage // otherwise, show all infos that don't have a specified stage - .filter((i) => (stage ? i.stage === stage : !i.stage)) - .map((i) => - i.isStatic ? ( - - ) : ( - - {i.value && ( - - {i.value} - - )} - - ) - ) + .filter((kv) => (stage ? kv.stage === stage : !kv.stage)) + .map((kv) => { + const key = `${kv.label}-${kv.value}`; + if (kv.type === 'message') { + return ; + } + + if (kv.type === 'dynamic-key-value') { + return ( + + {kv.value && ( + + {kv.value} + + )} + + ); + } + + if (kv.type === 'static-key-value') { + return ; + } + }) ); } @@ -161,8 +203,9 @@ function Stages({ error, hasElapsedTime = true, hasStageTime = true, - info, - messages, + postInfoBlock, + preInfoBlock, + stageInfoBlock, stageTracker, timerUnit = 'ms', title, @@ -170,11 +213,10 @@ function Stages({ return ( - {messages && messages.length > 0 && ( + + {preInfoBlock && ( - {messages?.map((message) => ( - {message} - ))} + )} @@ -212,18 +254,18 @@ function Stages({ )} - {info && status !== 'pending' && status !== 'skipped' && ( + {stageInfoBlock && status !== 'pending' && status !== 'skipped' && ( - + )} ))} - {info && ( + {postInfoBlock && ( - + )} @@ -238,35 +280,43 @@ function Stages({ } class CIMultiStageComponent> { - private seenStages: Set; + private seenStages: Set = new Set(); private data?: Partial; private startTime: number | undefined; private startTimes: Map = new Map(); + private lastUpdateTime: number; - private readonly info?: Array>; + private readonly postInfoBlock?: InfoBlock; + private readonly preInfoBlock?: InfoBlock; + private readonly stageInfoBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; private readonly hasStageTime?: boolean; private readonly timerUnit?: 'ms' | 's'; + private readonly messageTimeout = parseInt(env.SF_CI_MESSAGE_TIMEOUT ?? '5000', 10) ?? 5000; public constructor({ data, - info, + postInfoBlock, + preInfoBlock, showElapsedTime, showStageTime, + stageInfoBlock, stages, timerUnit, title, }: MultiStageComponentOptions) { this.title = title; this.stages = stages; - this.seenStages = new Set(); - this.info = info; + this.postInfoBlock = postInfoBlock; + this.preInfoBlock = preInfoBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; + this.stageInfoBlock = stageInfoBlock; this.timerUnit = timerUnit ?? 'ms'; this.data = data; + this.lastUpdateTime = Date.now(); ux.stdout(`───── ${this.title} ─────`); ux.stdout('Steps:'); @@ -292,7 +342,16 @@ class CIMultiStageComponent> { // do nothing break; case 'current': - this.startTimes.set(stage, Date.now()); + if (Date.now() - this.lastUpdateTime < this.messageTimeout) break; + this.lastUpdateTime = Date.now(); + if (!this.startTimes.has(stage)) this.startTimes.set(stage, Date.now()); + ux.stdout(`${icons.current} ${capitalCase(stage)}...`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); break; case 'failed': case 'skipped': @@ -306,10 +365,22 @@ class CIMultiStageComponent> { ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); } else if (status === 'skipped') { ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`); } else { ux.stdout(`${icons[status]} ${capitalCase(stage)}`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); } break; @@ -317,13 +388,6 @@ class CIMultiStageComponent> { // do nothing } } - - this.printInfo(); - } - - // eslint-disable-next-line class-methods-use-this - public addMessage(message: string): void { - ux.stdout(message); } public stop(stageTracker: StageTracker): void { @@ -334,18 +398,24 @@ class CIMultiStageComponent> { const displayTime = this.timerUnit === 'ms' ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); ux.stdout(`Elapsed time: ${displayTime}`); + ux.stdout(); } - this.printInfo(); + this.printInfo(this.preInfoBlock); + this.printInfo(this.postInfoBlock); } - private printInfo(): void { - if (!this.info) return; - ux.stdout(); - for (const info of this.info) { - const formattedData = info.get ? info.get(this.data as T) : undefined; - if (formattedData) { - ux.stdout(`${info.label}: ${formattedData}`); + private printInfo(infoBlock: InfoBlock | StageInfoBlock | undefined, indent = 0): void { + const spaces = ' '.repeat(indent); + if (infoBlock?.length) { + for (const info of infoBlock) { + const formattedData = info.get ? info.get(this.data as T) : undefined; + if (!formattedData) continue; + if (info.type === 'message') { + ux.stdout(`${spaces}${formattedData}`); + } else { + ux.stdout(`${spaces}${info.label}: ${formattedData}`); + } } } } @@ -357,9 +427,10 @@ export class MultiStageComponent> implements D private ciInstance: CIMultiStageComponent | undefined; private stageTracker: StageTracker; private stopped = false; - private messages: string[]; - private readonly info?: Array>; + private readonly postInfoBlock?: InfoBlock; + private readonly preInfoBlock?: InfoBlock; + private readonly stageInfoBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; @@ -368,11 +439,12 @@ export class MultiStageComponent> implements D public constructor({ data, - info, jsonEnabled, - messages, + postInfoBlock, + preInfoBlock, showElapsedTime, showStageTime, + stageInfoBlock, stages, timerUnit, title, @@ -380,53 +452,37 @@ export class MultiStageComponent> implements D this.data = data; this.stages = stages; this.title = title; - this.info = info; + this.postInfoBlock = postInfoBlock; + this.preInfoBlock = preInfoBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; this.timerUnit = timerUnit ?? 'ms'; this.stageTracker = new StageTracker(stages); - this.messages = messages ?? []; - - if (!jsonEnabled) { - if (isInCi) { - this.ciInstance = new CIMultiStageComponent({ - stages, - title, - info, - showElapsedTime, - showStageTime, - timerUnit, - data, - jsonEnabled, - }); - } else { - this.inkInstance = render( - - ); - } - } - } + this.stageInfoBlock = stageInfoBlock; + + if (jsonEnabled) return; - public addMessage(message: string): void { - if (this.stopped) return; - this.messages.push(message); if (isInCi) { - this.ciInstance?.addMessage(message); + this.ciInstance = new CIMultiStageComponent({ + stages, + title, + postInfoBlock, + preInfoBlock, + showElapsedTime, + showStageTime, + stageInfoBlock, + timerUnit, + data, + jsonEnabled, + }); } else { - this.inkInstance?.rerender( + this.inkInstance = render( > implements D error={error} hasElapsedTime={this.hasElapsedTime} hasStageTime={this.hasStageTime} - info={this.formatInfo()} - messages={this.messages} + postInfoBlock={this.formatKeyValuePairs(this.postInfoBlock)} + preInfoBlock={this.formatKeyValuePairs(this.preInfoBlock)} + stageInfoBlock={this.formatKeyValuePairs(this.stageInfoBlock)} stageTracker={this.stageTracker} timerUnit={this.timerUnit} title={this.title} @@ -492,8 +549,9 @@ export class MultiStageComponent> implements D > implements D > implements D } } - private formatInfo(): FormattedInfo[] { + private formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { return ( - this.info?.map((info) => { + infoBlock?.map((info) => { const formattedData = info.get ? info.get(this.data as T) : undefined; return { value: formattedData, - label: info.label, isBold: info.bold, color: info.color, - isStatic: info.static, - stage: info.stage, + type: info.type, + ...(info.type !== 'message' ? { label: info.label } : {}), + ...('stage' in info ? { stage: info.stage } : {}), }; }) ?? [] );