Visualize branches in ChangeQueues

The old status page visualized failing queue items (e.g. merge
conflicts) by creating a new "branch" in the change queue.

This change implements a similar functionality in the
PipelineDetailsView by nesting ProgressSteppers in a tree-like view to
visualize multiple branches of QueueItems within a ChangeQueue.

By utilizing a ProgressStepper for this purpose we can get rid of most
of the custom CSS code that was necessary to draw and connect the
"branching lines" in the old approach. We only need a little bit of CSS
to connect an inner ProgressStepper to the outer one.

In case a queue has multiple heads, each head will be rendered in a
separate tree (ProgressStepper).

Change-Id: Iee80b8d472cfbd8436301dff83edde5924bb7d08
This commit is contained in:
Felix Edel 2024-04-19 09:37:54 +02:00
parent 2c32ef906f
commit 23f8cab0af
2 changed files with 165 additions and 32 deletions

View File

@ -28,8 +28,146 @@ import {
import QueueItem from './QueueItem'
import { getQueueItemIconConfig } from './Misc'
// This function will create a "tree-like" data structure to visualize
// multiple branches in a single ChangeQueue.
// The data structure is basically a linked-list that contains
// additional branches for items that are no longer relevant for the
// main branch (e.g. merge conflicts in a dependent pipeline). The base
// for the data structure is the "items_behind" relation of queue items.
// As a result, each queue item will have a single item_behind in the
// the main branch, while all other items_behind are moved to different
// branches.
//
// In the example below the items A, B, D and F build the "main" branch,
// while the items C and E create new branches (e.g. due to merge
// conflicts). G is the item behind E, so it will also be part of the
// branch starting with E.
//
// A
// |
// B
// |-C
// D
// |-E
// | |
// F G
const createTree = (head) => {
// Root of the tree/linked list
let tree = null
// Map for easier lookup of items by their id
const itemsById = {}
// Create a copy of the original queue, so we can remove the current
// node while iterating over the list. Once the list is empty, we
// know that we have seen all items in the queue.
//let head = JSON.parse(JSON.stringify(_head))
// First iteration: Create map for "lookup by id"
head.forEach(item => {
itemsById[item.id] = item
})
// Second iteration: Move each item to the correct position within
// the tree
head.forEach(node => {
// node._next builds a linked-list to visualize the "main" branch
// of the queue.
node._next = null
// Branches will contain all items_behind which are not part of
// main branch.
node._branches = []
// Create the root of the tree
if (tree === null) {
tree = node
}
if (node.items_behind.length === 0) {
// Basically a continue for the forEach loop
return
}
// Copy the items_behind, so we pop() already assigned items without
// affecting the original array.
const items_behind = node.items_behind.slice()
if (items_behind.length > 1) {
// The last element in the list is the item_behind on the "main"
// branch
const item_behind = itemsById[items_behind.pop()]
node._next = item_behind
// All other items_behind are failing ones, so they should be
// added to separate branches
items_behind.forEach(item => {
const item_behind = itemsById[item]
node._branches.push(item_behind)
})
} else {
// We have only one element, so add it to the main branch
const item_behind = itemsById[items_behind.pop()]
node._next = item_behind
}
})
return tree
}
const Branch = ({ item, newBranch = false }) => {
// Recursively render QueueItems to visualize a ChangeQueue.
const iconConfig = getQueueItemIconConfig(item)
const Icon = iconConfig.icon
const step = (
<>
<ProgressStep
variant={iconConfig.variant}
id={item.id}
titleId={item.id}
icon={<Icon />}
style={{ marginBottom: '16px' }}
key={`ps-${item.id}`}
>
<QueueItem item={item} />
{/* To visualize a new branch, we put a ProgressStepper within
the current ProgressStep. */}
{item._branches.map(branch => (
<Branch item={branch} newBranch={true} key={`br-${item.id}`} />
))}
</ProgressStep>
{/* Items in the same branch must come after the current
ProgressStep. We don't want them to be nested. */}
{item._next !== null ? <Branch item={item._next} /> : ''}
</>
)
const wrappedStep = (
<div className="zuul-branch-wrapper">
<ProgressStepper isVertical className="zuul-queue-branch">
{step}
</ProgressStepper>
</div>
)
// If we want to start a new branch, we have to wrap the current
// branch into a new ProgressStepper.
if (newBranch) {
return wrappedStep
}
// Otherwise, just return the current step
return step
}
Branch.propTypes = {
item: PropTypes.object.isRequired,
newBranch: PropTypes.bool,
}
function ChangeQueue({ queue }) {
// TODO (felix): Use useMemo hook to cache the rendered tree across re-renders
const trees = []
queue.heads.forEach(head => (
trees.push(createTree(head))
))
return (
<>
<Card isPlain className="zuul-change-queue">
@ -43,38 +181,11 @@ function ChangeQueue({ queue }) {
: ''}
<CardBody>
<Panel>
{/* TODO (felix): Add the following CSS rules to the ::before
element of a nested ProgressStep connector:
transform: skew(45deg);
left: 30px;
top: 20px;
height: 20px;
That will draw a diagonal line between the inner
ProgressStep and the outer ProgressStep. Maybe something
like this would work to implement branching within a
ProgressStepper.
*/}
<ProgressStepper isVertical>
{queue.heads.map(head => (
head.map(item => {
const iconConfig = getQueueItemIconConfig(item)
const Icon = iconConfig.icon
return (
<ProgressStep
variant={iconConfig.variant}
id={item.id}
titleId={item.id}
icon={<Icon />}
style={{ marginBottom: '16px' }}
key={item.id}
>
<QueueItem item={item} />
</ProgressStep>
)
})
))}
</ProgressStepper>
{trees.map(tree => (
<ProgressStepper key={tree.id} isVertical>
<Branch item={tree} />
</ProgressStepper>
))}
</Panel>
</CardBody>
</Card >

View File

@ -342,6 +342,28 @@ a.refresh {
animation: progress-bar-stripes 1s linear infinite;
}
.pf-c-progress-stepper.zuul-queue-branch {
margin-top: 20px;
}
.zuul-branch-wrapper::before {
content: "";
position: absolute;
border-bottom: var(--pf-c-progress-stepper__step-connector--before--BorderRightWidth) solid var(--pf-c-progress-stepper__step-connector--before--BorderRightColor);
left: 15px;
height: 14px;
width: 40px;
}
/*.pf-c-progress-stepper.zuul-queue-branch > .pf-c-progress-stepper__step:first-of-type > .pf-c-progress-stepper__step-connector::before {
content: "";
position: absolute;
border-bottom: var(--pf-c-progress-stepper__step-connector--before--BorderRightWidth) solid var(--pf-c-progress-stepper__step-connector--before--BorderRightColor);
left: -18px;
top: 13px;
height: 0;
width: 40px;
}*/
.zuul-pipeline-header h3 {
font-weight: var(--pf-global--FontWeight--bold);
}