Skip to content

Commit

Permalink
Use breadth first flattening order for stages (#913)
Browse files Browse the repository at this point in the history
* Use breadth first flattening order for stages

* Add unit tests for stage sorting
  • Loading branch information
kalilsn authored Jan 23, 2025
1 parent 678e63b commit c8c1463
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 59 deletions.
24 changes: 10 additions & 14 deletions core/app/c/[communitySlug]/stages/components/StageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getStageActions } from "~/lib/db/queries";
import { getPubsWithRelatedValuesAndChildren } from "~/lib/server";
import { selectCommunityMembers } from "~/lib/server/member";
import { getStages } from "~/lib/server/stages";
import { getStageWorkflows } from "~/lib/stages";
import { getOrderedStages } from "~/lib/stages";
import { PubListSkeleton } from "../../pubs/PubList";
import { StagePubActions } from "./StagePubActions";

Expand All @@ -31,22 +31,18 @@ export async function StageList(props: Props) {
selectCommunityMembers({ communityId }).execute(),
]);

const stageWorkflows = getStageWorkflows(communityStages);
const stages = getOrderedStages(communityStages);

return (
<div>
{stageWorkflows.map((stages) => (
<div key={stages[0].id}>
{stages.map((stage) => (
<StageCard
userId={props.userId}
key={stage.id}
stage={stage}
members={communityMembers}
pageContext={props.pageContext}
/>
))}
</div>
{stages.map((stage) => (
<StageCard
userId={props.userId}
key={stage.id}
stage={stage}
members={communityMembers}
pageContext={props.pageContext}
/>
))}
</div>
);
Expand Down
187 changes: 187 additions & 0 deletions core/lib/stages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { describe, expect, test } from "vitest";

import type { CommunitiesId, StagesId } from "db/public";

import { getOrderedStages } from "./stages";

const date = new Date();
const biconnectedStages = [
{
moveConstraints: [
{
id: "88da690f-60c2-4c26-96eb-471539376625" as StagesId,
name: "circular stage B",
},
],
moveConstraintSources: [
{
id: "88da690f-60c2-4c26-96eb-471539376625" as StagesId,
name: "circular stage B",
},
],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "9d50ab3a-30f4-4317-9e77-27b63daf5ea2" as StagesId,
createdAt: date,
updatedAt: date,
name: "circular stage A",
order: "aa",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
{
moveConstraints: [
{
id: "9d50ab3a-30f4-4317-9e77-27b63daf5ea2" as StagesId,
name: "circular stage A",
},
],
moveConstraintSources: [
{
id: "9d50ab3a-30f4-4317-9e77-27b63daf5ea2" as StagesId,
name: "circular stage A",
},
],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "88da690f-60c2-4c26-96eb-471539376625" as StagesId,
createdAt: date,
updatedAt: date,
name: "circular stage B",
order: "aa",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
];

const stages = [
{
moveConstraints: [
{
id: "c5d4e451-10c8-42f5-96ce-32a436f39cf0" as StagesId,
name: "Published",
},
],
moveConstraintSources: [
{
id: "0d06b908-c1fd-45ce-aa5f-9838cba37331" as StagesId,
name: "Submitted",
},
],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "19e075dd-fa39-4b14-87f0-894a354c530b" as StagesId,
createdAt: date,
updatedAt: date,
name: "Review",
order: "aa",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
{
moveConstraints: [
{
id: "c4cdab92-7eed-4f6e-9c70-fcac1ca32be0" as StagesId,
name: "In Production",
},
],
moveConstraintSources: [],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "437c2458-940f-4923-b415-60d2033b1044" as StagesId,
createdAt: date,
updatedAt: date,
name: "Extra root",
order: "aa",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
{
moveConstraints: [
{
id: "c4cdab92-7eed-4f6e-9c70-fcac1ca32be0" as StagesId,
name: "In Production",
},
{
id: "19e075dd-fa39-4b14-87f0-894a354c530b" as StagesId,
name: "Review",
},
],
moveConstraintSources: [],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "0d06b908-c1fd-45ce-aa5f-9838cba37331" as StagesId,
createdAt: date,
updatedAt: date,
name: "Submitted",
order: "aa",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
{
moveConstraints: [
{
id: "c5d4e451-10c8-42f5-96ce-32a436f39cf0" as StagesId,
name: "Published",
},
],
moveConstraintSources: [
{
id: "0d06b908-c1fd-45ce-aa5f-9838cba37331" as StagesId,
name: "Submitted",
},
{
id: "437c2458-940f-4923-b415-60d2033b1044" as StagesId,
name: "Extra root",
},
],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "c4cdab92-7eed-4f6e-9c70-fcac1ca32be0" as StagesId,
createdAt: date,
updatedAt: date,
name: "In Production",
order: "ff",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
{
moveConstraints: [],
moveConstraintSources: [
{
id: "c4cdab92-7eed-4f6e-9c70-fcac1ca32be0" as StagesId,
name: "In Production",
},
{
id: "19e075dd-fa39-4b14-87f0-894a354c530b" as StagesId,
name: "Review",
},
],
pubsCount: 0,
memberCount: 0,
actionInstancesCount: 0,
id: "c5d4e451-10c8-42f5-96ce-32a436f39cf0" as StagesId,
createdAt: date,
updatedAt: date,
name: "Published",
order: "gg",
communityId: "5419787f-4958-4a47-8519-eefc85613177" as CommunitiesId,
},
];

describe("stage sorting", () => {
test("it includes stages from biconnected graphs (even though they can't be sorted)", () => {
const sorted = getOrderedStages(biconnectedStages);
expect(sorted.length).toBe(2);
});

test("it renders the last stage last", () => {
const sorted = getOrderedStages(stages);
expect(sorted[sorted.length - 1].name).toBe("Published");
});

test("it doesn't duplicate stages when there are multiple roots", () => {
const sorted = getOrderedStages(stages);
expect(sorted.length).toEqual(new Set(sorted.map((stage) => stage.name)).size);
});
});
72 changes: 27 additions & 45 deletions core/lib/stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,6 @@ export type StagesById<T extends CommunityStage = CommunityStage> = {
[key: StagesId]: T;
};

/**
* Takes a stage, a map of stages to their IDs, and an optional list of stages
* that have been visited so far. Returns a list of stages that can be reached
* from the stage provided without mutating the visited stages.
* @param stage - The current stage
* @param stages - A map of stage IDs to stage objects
* @param visited - (Optional) An array of visited stages, defaults to an empty array
* @returns A new array of stages that have been visited
*/
function createStageList<T extends CommunityStage>(
stage: T,
stages: StagesById,
visited: Array<T> = []
): Array<T> {
if (!stage) {
return visited;
}

// If the stage has already been visited, return the current visited list
if (visited.includes(stage)) {
return visited;
}

// Add the current stage to the visited list (non-mutating)
const newVisited = [...visited, stage];

// Recursively process the stages reachable from this stage
return stage.moveConstraints.reduce((acc, constraint) => {
const nextStage = stages[constraint.id];
// If the next stage is undefined, just return the accumulator
// This would happen if the move constraint isn't in the stage list (because of user permissions)
if (!stage) {
return acc;
}
return createStageList<T>(nextStage as T, stages, acc);
}, newVisited);
}

/**
*
* @param stages
Expand All @@ -56,11 +18,12 @@ export const makeStagesById = <T extends { id: StagesId }>(stages: T[]): { [key:
};

/**
* this function takes a list of stages and recursively builds a topological sort of the stages
* This function takes a list of stages and returns them in the order of a breadth-first flattening
* of their graph. When the stages form multiple independent graphs
* @param stages
* @returns
*/
export function getStageWorkflows<T extends CommunityStage>(stages: T[]): Array<Array<T>> {
export function getOrderedStages<T extends CommunityStage>(stages: T[]): Array<T> {
const stagesById = makeStagesById(stages);
// find all stages with edges that only point to them
// also make sure to filter to only move constraints that there are stages for (permission restrictions may return more move constraint stages than a user can see)
Expand All @@ -70,11 +33,30 @@ export function getStageWorkflows<T extends CommunityStage>(stages: T[]): Array<
}
return !stage.moveConstraintSources.every((constraint) => stagesById[constraint.id]);
});
// for each stage, create a list of stages that can be reached from it
const stageWorkflows = stageRoots.map((stage) => {
return createStageList(stage, stagesById);
});
return stageWorkflows as T[][];

const orderedStages = new Set<T>();
const stagesQueue: T[] = stageRoots;
// Breadth-first traversal of the graph(s)
while (stagesQueue.length > 0) {
const stage = stagesQueue.shift();
if (!stage) {
// This should be unreachable because of the condition in the while, but Typescript
// doesn't know that
break;
}
orderedStages.add(stage);
stage.moveConstraints.forEach((destinationStage) =>
stagesQueue.push(stagesById[destinationStage.id])
);
}

// Because the algorithm above starts with "root" stages only, it will totally exclude graphs
// where every stage is part of a cycle (biconnected graphs). Since we don't know where those
// graphs should begin, we skip sorting them but make sure their stages are included in the
// output
stages.forEach((stage) => orderedStages.add(stage));

return [...orderedStages];
}

// this function takes a stage and a map of stages and their IDs and returns a list of stages that can be reached from the stage provided
Expand Down

0 comments on commit c8c1463

Please sign in to comment.