From 6fbe2a7b698916ced0efa5246763b5e4f8392a0c Mon Sep 17 00:00:00 2001 From: Haydn Ewers <27211+haydn@users.noreply.github.com> Date: Sat, 17 Jun 2023 17:30:17 +1000 Subject: [PATCH] Add projects --- components/Layout.tsx | 1 + components/ProjectRelationshipGraph.css.ts | 8 + components/ProjectRelationshipGraph.tsx | 321 +++++++++++++++++++++ components/RelationshipGraph.tsx | 3 +- package.json | 2 + pages/projects.page.tsx | 75 +++++ utils/linear.ts | 12 + yarn.lock | 12 + 8 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 components/ProjectRelationshipGraph.css.ts create mode 100644 components/ProjectRelationshipGraph.tsx create mode 100644 pages/projects.page.tsx diff --git a/components/Layout.tsx b/components/Layout.tsx index 4d9b173..87e1b90 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -12,6 +12,7 @@ const Layout = ({ children }: Props) => ( Issues Effort Value + Projects Settings
{children}
diff --git a/components/ProjectRelationshipGraph.css.ts b/components/ProjectRelationshipGraph.css.ts new file mode 100644 index 0000000..0dd20f9 --- /dev/null +++ b/components/ProjectRelationshipGraph.css.ts @@ -0,0 +1,8 @@ +import { style } from "@vanilla-extract/css"; +import theme from "../utils/theme.css"; + +export const card = style({ + border: `1px solid ${theme.color.background}`, + borderRadius: 20, + overflow: "hidden", +}); diff --git a/components/ProjectRelationshipGraph.tsx b/components/ProjectRelationshipGraph.tsx new file mode 100644 index 0000000..7997700 --- /dev/null +++ b/components/ProjectRelationshipGraph.tsx @@ -0,0 +1,321 @@ +import { ParentSize } from "@visx/responsive"; +import { addEdge, addVertex, create, isCyclic } from "graph-fns"; +import { useEffect, useState } from "react"; +import NetworkGraph from "../components/NetworkGraph"; +import { Data } from "../utils/AppContext"; +import { RelationSummary } from "../utils/linear"; +import Layer from "./Layer"; +import { card } from "./ProjectRelationshipGraph.css"; +import emoji from "node-emoji"; + +type Item = + | { type: "project"; id: string; label: string } + | { type: "issue"; id: string; label: string }; + +const getAncestors = ( + relations: Array, + identifiers: Array +): Array => { + const ancestors = relations + .filter((relation) => identifiers.includes(relation.relatedIssueIdentifier)) + .map((relation) => relation.issueIdentifier); + return ancestors.length > 0 + ? ancestors.concat( + ancestors.flatMap((ancestor) => getAncestors(relations, [ancestor])) + ) + : []; +}; + +const getDescendants = ( + relations: Array, + identifiers: Array +): Array => { + const descendants = relations + .filter((relation) => identifiers.includes(relation.issueIdentifier)) + .map((relation) => relation.relatedIssueIdentifier); + return descendants.length > 0 + ? descendants.concat( + descendants.flatMap((descendant) => + getDescendants(relations, [descendant]) + ) + ) + : []; +}; + +const ProjectRelationshipGraph = ({ + data, + projectId, +}: { + data: Data; + projectId: string; +}) => { + const [showing, setShowing] = useState(false); + const [forceStrength, setForceStrength] = useState(1500); + + useEffect(() => { + const escapeListener = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setShowing(false); + } + }; + + document.addEventListener("keydown", escapeListener); + + return () => { + document.removeEventListener("keydown", escapeListener); + }; + }); + + const projectIssueIdentifiers = data.issues + .filter( + (issue) => + issue.state === "triage" || + issue.state === "backlog" || + issue.state === "unstarted" + ) + .filter((issue) => issue.projectId === projectId) + .map((issue) => issue.identifier); + + const blockingRelations = data.relations.filter( + (relation) => relation.type === "blocks" + ); + + const ancestors = getAncestors(blockingRelations, projectIssueIdentifiers); + const descendants = getDescendants( + blockingRelations, + projectIssueIdentifiers + ); + + if (ancestors.length === 0 && descendants.length === 0) { + return

-

; + } + + const ancestorItems: Array = []; + + for (let ancestor of ancestors) { + const issue = data.issues.find((issue) => issue.identifier === ancestor); + if (!issue) continue; + if (issue.projectId !== undefined && issue.projectName !== undefined) { + ancestorItems.push({ + type: "project", + id: issue.projectId, + label: `${ + issue.projectIcon && emoji.hasEmoji(issue.projectIcon) + ? emoji.get(issue.projectIcon) + " " + : "" + }${issue.projectName}`, + }); + } else { + ancestorItems.push({ + type: "issue", + id: issue.identifier, + label: issue.title, + }); + } + } + + const descendantItems: Array = []; + + for (let descendant of descendants) { + const issue = data.issues.find((issue) => issue.identifier === descendant); + if (!issue) continue; + if (issue.projectId !== undefined && issue.projectName !== undefined) { + descendantItems.push({ + type: "project", + id: issue.projectId, + label: `${ + issue.projectIcon && emoji.hasEmoji(issue.projectIcon) + ? emoji.get(issue.projectIcon) + " " + : "" + }${issue.projectName}`, + }); + } else { + descendantItems.push({ + type: "issue", + id: issue.identifier, + label: issue.title, + }); + } + } + + const all = [...ancestors, ...projectIssueIdentifiers, ...descendants]; + + let graph = create(); + + const items: Array = []; + + for (let relation of blockingRelations) { + if ( + !all.includes(relation.issueIdentifier) || + !all.includes(relation.relatedIssueIdentifier) + ) { + continue; + } + + const issue = data.issues.find( + (issue) => issue.identifier === relation.issueIdentifier + ); + const relatedIssue = data.issues.find( + (issue) => issue.identifier === relation.relatedIssueIdentifier + ); + + if (!issue || !relatedIssue) continue; + if (issue.state === "completed" || relatedIssue.state === "completed") + continue; + if (issue.state === "canceled" || relatedIssue.state === "canceled") + continue; + + const issueItem: Item = + issue.projectId && issue.projectName + ? { + type: "project", + id: issue.projectId, + label: `${ + issue.projectIcon && emoji.hasEmoji(issue.projectIcon) + ? emoji.get(issue.projectIcon) + " " + : "" + }${issue.projectName}`, + } + : { + type: "issue", + id: issue.identifier, + label: issue.title + ? `[${issue.identifier}] ${issue.title}` + : issue.identifier, + }; + + const relatedIssueItem: Item = + relatedIssue.projectId && relatedIssue.projectName + ? { + type: "project", + id: relatedIssue.projectId, + label: `${ + relatedIssue.projectIcon && + emoji.hasEmoji(relatedIssue.projectIcon) + ? emoji.get(relatedIssue.projectIcon) + " " + : "" + }${relatedIssue.projectName}`, + } + : { + type: "issue", + id: relatedIssue.identifier, + label: relatedIssue.title + ? `[${relatedIssue.identifier}] ${relatedIssue.title}` + : relatedIssue.identifier, + }; + + if (issueItem.id === relatedIssueItem.id) continue; + + if (!items.some((item) => item.id === issueItem.id)) { + items.push(issueItem); + graph = addVertex(graph, issueItem.id); + } + + if (!items.some((item) => item.id === relatedIssueItem.id)) { + items.push(relatedIssueItem); + graph = addVertex(graph, relatedIssueItem.id); + } + + graph = addEdge(graph, [issueItem.id, relatedIssueItem.id]); + } + + if (items.length === 0) { + return

-

; + } + + return showing ? ( + +
+
+ + {({ width, height }) => ( +
+ { + const item = items.find((item) => item.id === id); + return item?.label ?? "Unknown"; + }} + color={(id) => { + // const item = items.find((item) => item.id === id); + return id === projectId + ? "#000" + : ancestorItems.some((item) => item.id === id) + ? "#900" + : "#090"; + }} + // label={(identifier) => { + // const issue = data.issues.find( + // (issue) => issue.identifier === identifier + // ); + // return issue?.title + // ? `[${identifier}] ${issue?.title} (${issue.projectName})` + // : identifier; + // }} + // color={(identifier) => { + // const issue = data.issues.find( + // (issue) => issue.identifier === identifier + // ); + // return issue?.state === "completed" || + // issue?.state === "canceled" + // ? "#999" + // : projectIssueIdentifiers.includes(identifier) + // ? "#000" + // : ancestors.includes(identifier) + // ? "#900" + // : "#090"; + // }} + // textDecoration={(identifier) => { + // const issue = data.issues.find( + // (issue) => issue.identifier === identifier + // ); + // return issue?.state === "completed" || + // issue?.state === "canceled" + // ? "line-through" + // : "none"; + // }} + /> +
+ )} +
+
+ + { + setForceStrength(parseInt(value, 10)); + }} + style={{ position: "absolute", left: 20, top: 20 }} + /> +
+
+ ) : ( + + ); +}; + +export default ProjectRelationshipGraph; diff --git a/components/RelationshipGraph.tsx b/components/RelationshipGraph.tsx index c159112..69ea530 100644 --- a/components/RelationshipGraph.tsx +++ b/components/RelationshipGraph.tsx @@ -132,7 +132,8 @@ const RelationshipGraph = ({ const issue = data.issues.find( (issue) => issue.identifier === identifier ); - return issue?.state === "canceled" + return issue?.state === "completed" || + issue?.state === "canceled" ? "line-through" : "none"; }} diff --git a/package.json b/package.json index 685e0d8..93188bb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "graph-fns": "^0.3.0", "marked": "^4.0.18", "next": "12.2.5", + "node-emoji": "1", "react": "18.2.0", "react-dom": "18.2.0", "react-use": "^17.4.0", @@ -30,6 +31,7 @@ "@types/dedent": "^0.7.0", "@types/marked": "^4.0.3", "@types/node": "18.7.3", + "@types/node-emoji": "1", "@types/react": "18.0.17", "@types/react-dom": "18.0.6", "@types/uuid": "^8.3.4", diff --git a/pages/projects.page.tsx b/pages/projects.page.tsx new file mode 100644 index 0000000..88266f3 --- /dev/null +++ b/pages/projects.page.tsx @@ -0,0 +1,75 @@ +import type { NextPage } from "next"; +import { useContext } from "react"; +import Layout from "../components/Layout"; +import AppContext from "../utils/AppContext"; +import ProjectRelationshipGraph from "../components/ProjectRelationshipGraph"; +import emoji from "node-emoji"; + +const ValuePage: NextPage = () => { + const { data } = useContext(AppContext); + + const projects: Array<{ id: string; name: string; issues: number }> = []; + + for (let issue of data.issues.filter( + (issue) => + issue.state === "triage" || + issue.state === "backlog" || + issue.state === "unstarted" + )) { + if (!issue.projectId || !issue.projectName) continue; + + const existingProject = projects.find( + (project) => project.id === issue.projectId + ); + + if (existingProject) { + existingProject.issues += 1; + } else { + projects.push({ + id: issue.projectId, + name: `${ + issue.projectIcon && emoji.hasEmoji(issue.projectIcon) + ? emoji.get(issue.projectIcon) + " " + : "" + }${issue.projectName}`, + issues: 1, + }); + } + } + + return ( + + + + + + + + + + + {projects + .sort((a, b) => + emoji.strip(a.name).localeCompare(emoji.strip(b.name)) + ) + .map((project) => { + return ( + + + + + + ); + })} + +
NameIssuesDependencies
{project.name}{project.issues} + +
+
+ ); +}; + +export default ValuePage; diff --git a/utils/linear.ts b/utils/linear.ts index c0f25b5..f7bdc4b 100644 --- a/utils/linear.ts +++ b/utils/linear.ts @@ -12,7 +12,9 @@ type IssueSummary = { | "started" | "completed" | "canceled"; + projectId: string | undefined; projectName: string | undefined; + projectIcon: string | undefined; cycleNumber: number | undefined; }; @@ -21,7 +23,9 @@ type IssueDetail = { identifier: string; title: string; description: string; + projectId: string | undefined; projectName: string | undefined; + projectIcon: string | undefined; labels: Array; parentIdentifier: string | undefined; parentTitle: string | undefined; @@ -95,7 +99,9 @@ const getIssues = async ( } title project { + id name + icon } cycle { number @@ -118,7 +124,9 @@ const getIssues = async ( const result = (data?.team?.issues?.edges ?? []).map((edge: any) => ({ ...edge?.node, state: edge?.node.state.type, + projectId: edge?.node.project?.id, projectName: edge?.node.project?.name, + projectIcon: edge?.node.project?.icon, cycleNumber: edge?.node.cycle?.number, })); @@ -148,7 +156,9 @@ const getIssue = async ( title description project { + id name + icon } labels { edges { @@ -203,7 +213,9 @@ const getIssue = async ( identifier: data.issue.identifier, title: data.issue.title, description: data.issue.description, + projectId: data.issue.project?.id, projectName: data.issue.project?.name, + projectIcon: data.issue.project?.icon, labels: data.issue.labels.edges.map((edge: any) => edge.node.name), parentIdentifier: data.issue.parent?.identifier, parentTitle: data.issue.parent?.title, diff --git a/yarn.lock b/yarn.lock index 03ebd4f..4e1b516 100644 --- a/yarn.lock +++ b/yarn.lock @@ -225,6 +225,11 @@ resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4" integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg== +"@types/node-emoji@1": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.2.tgz#8f24f44dcfcbb69dcbf5a64e7f4df3c1900097fd" + integrity sha512-PfF1qL/9veo8BSHLV84C9ORNr3lHSlnWJ6yU8OdNufoftajeWHTLVbGHvp2B7e7DPDS9gMs6cfeSsqo5rqSitg== + "@types/node@18.7.3": version "18.7.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.3.tgz#432c89796eab539b7a30b7b8801a727b585238a4" @@ -1705,6 +1710,13 @@ next@12.2.5: "@next/swc-win32-ia32-msvc" "12.2.5" "@next/swc-win32-x64-msvc" "12.2.5" +node-emoji@1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" + integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== + dependencies: + lodash "^4.17.21" + node-releases@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" -- 2.51.2