diff --git a/src/client/components/ErrorAlert.tsx b/src/client/components/ErrorAlert.tsx index 7bc334c1..768fa3bd 100644 --- a/src/client/components/ErrorAlert.tsx +++ b/src/client/components/ErrorAlert.tsx @@ -6,6 +6,7 @@ import { ChakraClip } from './ChakraClipboard'; export interface ErrorAlertProps { error: ErrorLike + status?: "error" | "info" | "warning" | "success" | "neutral" } export const ErrorAlert = (props: ErrorAlertProps) => { @@ -17,7 +18,7 @@ export const ErrorAlert = (props: ErrorAlertProps) => { return ( - + {props.error.name ?? 'Error'} diff --git a/src/client/components/TransformSteps.tsx b/src/client/components/TransformSteps.tsx index c2f43695..9e1c4c54 100644 --- a/src/client/components/TransformSteps.tsx +++ b/src/client/components/TransformSteps.tsx @@ -1,20 +1,85 @@ -import { ComponentProps, Fragment } from "react" +import { ComponentProps, Fragment, useMemo } from "react" import { Timeline, Icon, Span, Stack, Heading, Box, Text } from '@chakra-ui/react'; import { JsonPlayObject, LifecycleStep } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; import { BiWrench } from "react-icons/bi"; +import { BsSkipForward } from "react-icons/bs"; +import { LuCheck, LuCircleX } from "react-icons/lu"; +import { BsStoplights } from "react-icons/bs"; +import { BsExclamationTriangle, BsExclamationOctagon } from "react-icons/bs"; +import { FaStop } from "react-icons/fa"; import { MdMusicNote } from "react-icons/md"; import { ChakraCodeBlockShort, ChakraPlainBlockShort } from "./CodeBlock"; import { JsonDiffPatch } from "./JsonDiff"; import { jdiff } from "../../core/DataUtils"; import { MSCollapsible, MSCollapsibleExternalProps } from "./MSCollapsible"; +import { Muted } from "./Typography"; export interface LifeycleStepsTimelineProps extends MSCollapsibleExternalProps { steps: LifecycleStep[] original: JsonPlayObject } +const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.Element[], JsonPlayObject?] => { + + let currentPlay: JsonPlayObject = JSON.parse(JSON.stringify(original)); + let patchFailed = false; + + const diffElements: JSX.Element[] | null = []; + let index = 0; + + for (const step of steps) { + index++; + const { + patch, + error + } = step; + + if (patch === undefined) { + if (error !== undefined) { + diffElements.push(null); + } else { + diffElements.push(Play was identical after Transform.); + } + continue; + } + + if (patchFailed) { + diffElements.push(); + continue; + } + let left: JsonPlayObject = JSON.parse(JSON.stringify(currentPlay)); + left.data.meta = { + ...(left.data.meta ?? {}), + brainz: { + ...(left.data.meta?.brainz ?? {}) + } + } + currentPlay.data.meta = { + ...(currentPlay.data.meta ?? {}), + brainz: { + ...(currentPlay.data.meta?.brainz ?? {}) + } + } + + try { + currentPlay = jdiff.patch(currentPlay, patch) as JsonPlayObject; + diffElements.push( + + + + ) + } catch (e) { + diffElements.push(); + patchFailed = true; + } + + } + + return [diffElements, patchFailed !== undefined ? currentPlay : undefined] +} + export const TransformSteps = (props: LifeycleStepsTimelineProps) => { const { steps, @@ -22,8 +87,7 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { collapsibleOpen } = props; - let currentPlay: JsonPlayObject = JSON.parse(JSON.stringify(original)), - patchFailed = false; + const [diffs, finalPlay] = useMemo(() => diffElements(original, steps), [steps, original]); return ( @@ -32,69 +96,69 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { patch, inputs, source, + error, + flowKnownState, + flowReason, + flowResult, name } = x; - let err: Error; - let left: JsonPlayObject; - if(!patchFailed) { - left = JSON.parse(JSON.stringify(currentPlay)); - left.data.meta = { - ...(left.data.meta ?? {}), - brainz: { - ...(left.data.meta?.brainz ?? {}) - } + let timelineIcon: JSX.Element, + iconProps: Record, + summary: JSX.Element, + alertStatus: "error" | "info" | "warning" | "success" | "neutral"; + if(error === undefined) { + timelineIcon = ; + iconProps = flowResult === 'continue' ? {color: "green.focusRing"} : {color: "red.focusRing"}; + summary = was completed{flowResult === 'stop' ? and stopped due to onSuccess condition : null}; + if(patch === undefined) { + summary = {summary}with no change to Play; } - currentPlay.data.meta = { - ...(currentPlay.data.meta ?? {}), - brainz: { - ...(currentPlay.data.meta?.brainz ?? {}) + } else { + if(flowKnownState === 'skip') { + timelineIcon = ; + summary = was skipped.; + alertStatus = 'info'; + } else if(flowKnownState === 'prereq') { + timelineIcon = ; + iconProps = {color: "orange.focusRing"}; + summary = was not completed due to prerequisite failure.; + alertStatus = "warning"; + } else { + if(flowResult === 'continue') { + timelineIcon = ; + iconProps = {color: "orange.focusRing"}; + summary = encountered an error but will continue due to onFailure condition.; + } else { + timelineIcon = ; + iconProps = {color: "red.focusRing"}; + summary = encountered an error. } } } - - if (!patchFailed && patch !== undefined) { - try { - currentPlay = jdiff.patch(currentPlay, patch) as JsonPlayObject; - } catch (e) { - err = new Error('Could not patch Play object', { cause: e }); - patchFailed = true; - } - } - let diffElm: JSX.Element; - - if(err) { - diffElm = - } else if(patch === undefined) { - diffElm = Play was identical after Transform. - } else if(patchFailed) { - diffElm = - } else { - diffElm = - - ; - } - - const showAnyDetails = inputs !== undefined || diffElm !== undefined || err !== undefined; + return - - + + {timelineIcon} - {name} with {source} + {name} with {source} {summary} - - {showAnyDetails ? + + {error !== undefined ? : null} - Diff - {diffElm} - {inputs !== undefined ? ( + {diffs[index] !== null ? ( + + Diff + {diffs[index]} + ) : null} + {inputs !== undefined && inputs.length > 0 ? ( Inputs @@ -103,11 +167,11 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { })} ) : null} - : null } + })} - {currentPlay !== false ? ( + {finalPlay !== undefined ? ( @@ -121,7 +185,7 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { Final Playafter all Transforms - + ) : null} diff --git a/src/client/components/Typography.tsx b/src/client/components/Typography.tsx new file mode 100644 index 00000000..bf3dfb75 --- /dev/null +++ b/src/client/components/Typography.tsx @@ -0,0 +1,4 @@ +import { Span } from '@chakra-ui/react'; +import { ComponentProps } from 'react'; + +export const Muted = (props: ComponentProps = {}) => \ No newline at end of file diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index 9ce1bbfa..679fe8a9 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -12,6 +12,7 @@ import { jdiff } from '../../DataUtils.js'; import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js'; import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js'; +import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError } from '../../../backend/common/errors/MSErrors.js'; interface BlockPath { key: string, parent: string }; type BlockPaths = BlockPath[]; @@ -100,8 +101,8 @@ export interface GeneratePlayWithLifecycleOptions { opts?: GeneratePlayOpts }, lifecycleSteps?: { - preCompare?: boolean | number - postCompare?: boolean | number + preCompare?: number | (boolean | 'prereq' | 'skipped' | 'stop' | 'continuewitherror')[] + postCompare?: number | (boolean | 'prereq' | 'skipped'| 'stop' | 'continuewitherror')[] } } @@ -110,8 +111,8 @@ export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions const { original: originalOpts = {}, lifecycleSteps: { - preCompare = true, - postCompare = true + preCompare, + postCompare, } = {} } = opts; @@ -129,40 +130,42 @@ export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions let steps: LifecycleStep[] = []; let transformedPlay = clone(original); - if(typeof preCompare === 'number') { - for(let i = 0; i < preCompare; i++) { - const step = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); - steps.push(step[0]); - transformedPlay = step[1]; - } - } else if(preCompare === true) { - const firstStep = faker.datatype.boolean(0.7) ? generateLifecycleStep(transformedPlay, {name: 'preCompare'}) : undefined; - if(firstStep !== undefined) { - transformedPlay = firstStep[1]; - steps.push(firstStep[0]); - while(faker.datatype.boolean(0.2)) { - const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); - steps.push(pc); - transformedPlay = modified; + if(preCompare !== undefined) { + if(typeof preCompare === 'number') { + for(let i = 0; i < preCompare; i++) { + const step = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); + steps.push(step[0]); + transformedPlay = step[1]; + } + } else { + for (const prec of preCompare) { + const [step, modified] = generateLifecycleStep(transformedPlay, { name: 'preCompare', error: prec === true ? undefined : prec === false ? true : prec }) + steps.push(step); + if (step.flowResult === 'continue') { + transformedPlay = modified; + } else { + break; + } } } } - if(typeof postCompare === 'number') { - for(let i = 0; i < postCompare; i++) { - const step = generateLifecycleStep(transformedPlay, {name: 'postCompare'}); - steps.push(step[0]); - transformedPlay = step[1]; - } - } else if(postCompare === true) { - const lastStep = faker.datatype.boolean(0.5) ? generateLifecycleStep(transformedPlay, {name: 'postCompare'}) : undefined; - if(lastStep !== undefined) { - transformedPlay = lastStep[1]; - steps.push(lastStep[0]); - while(faker.datatype.boolean(0.1)) { - const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'postCompare'}); - steps.push(pc); - transformedPlay = modified; + if(postCompare !== undefined) { + if (typeof postCompare === 'number') { + for (let i = 0; i < postCompare; i++) { + const step = generateLifecycleStep(transformedPlay, { name: 'postCompare' }); + steps.push(step[0]); + transformedPlay = step[1]; + } + } else { + for (const postc of postCompare) { + const [step, modified] = generateLifecycleStep(transformedPlay, { name: 'preCompare', error: postc === true ? undefined : postc === false ? true : postc }) + steps.push(step); + if (step.flowResult === 'continue') { + transformedPlay = modified; + } else { + break; + } } } } @@ -221,6 +224,7 @@ export interface GenerateLifecycleOptions { name?: string source?: string equal?: boolean + error?: boolean | 'prereq' | 'skipped' | 'stop' | 'continuewitherror' inputCount?: number } @@ -231,6 +235,7 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO name = ['preCompare', 'postCompare'][faker.number.int({ min: 0, max: 1 })], source = `${play.meta?.source}-${faker.word.noun()}`, equal = faker.datatype.boolean(0.1), + error = false, inputCount } = opts; @@ -239,6 +244,7 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO const step: LifecycleStep = { name, source, + flowResult: 'continue', inputs } @@ -246,6 +252,32 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO return [step, play]; } + if(error !== false) { + if(error === true) { + step.flowResult = 'stop'; + step.flowReason = 'Error encountered while transforming'; + step.error = new Error('Failed to do something', {cause: new Error('Oops it borked.')}); + } else if(error === 'prereq') { + step.flowResult = 'stop'; + step.flowKnownState = 'prereq'; + step.flowReason = 'Transform could not be completed due to prerequisite failure'; + step.error = mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')})); + } else if(error === 'stop') { + step.flowResult = 'stop'; + } else if(error === 'continuewitherror') { + step.flowResult = 'continue'; + step.flowReason = 'Transform encountered an error but continuing due to onFailure: continue'; + step.error = mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with')); + } else { + step.flowResult = 'continue'; + step.flowKnownState = 'skip'; + step.flowReason = `Stage ${name} was skipped`; + step.error = mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true})); + } + + return [step, play]; + } + play.data.meta = { ...(play.data?.meta ?? {}), brainz: { diff --git a/src/stories/ActivityTimeline.stories.tsx b/src/stories/ActivityTimeline.stories.tsx index 3582843f..8616f9a9 100644 --- a/src/stories/ActivityTimeline.stories.tsx +++ b/src/stories/ActivityTimeline.stories.tsx @@ -8,6 +8,7 @@ import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../core/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; +import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ @@ -23,6 +24,7 @@ const meta = preview.meta({ args: { play: examplePlay() }, + render: function Render(args, { loaded: { play } }) { return () }, decorators: [ (Story) => (), ] @@ -31,12 +33,79 @@ decorators: [ // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const ActivityTimelineStory = meta.story({ - //render: function Render(args) { return () } + loaders: [ + async () => { + const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle( + { + lifecycleSteps: { + preCompare: 1, + postCompare: 1, + } + } + )) + return {play: scrobbleError}; + } + ], }); export const ScrobbleError = meta.story({ - args: { - play: lastfmErrorExample() - } + loaders: [ + async () => { + const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true}); + return {play: scrobbleError}; + } + ], +}); + +export const TransformError = meta.story({ + loaders: [ + async () => { + const scrobbleError = await generatePlayWithLifecycle({ + lifecycleSteps: { + preCompare: 2, + postCompare: [false], + } + }); + return {play: scrobbleError}; + } + ], +}); + +export const TransformSkip = meta.story({ + loaders: [ + async () => { + const play = await generatePlayWithLifecycle({ + lifecycleSteps: { + preCompare: [true, 'skipped', true], + } + }); + return {play}; + } + ], +}); + +export const TransformPrereq = meta.story({ + loaders: [ + async () => { + const play = await generatePlayWithLifecycle({ + lifecycleSteps: { + preCompare: [true, 'prereq'], + } + }); + return {play}; + } + ], }); +export const TransformStop = meta.story({ + loaders: [ + async () => { + const play = await generatePlayWithLifecycle({ + lifecycleSteps: { + preCompare: [true, 'stop'], + } + }); + return {play}; + } + ], +});