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 (
+
+
+
+
+ | Name |
+ Issues |
+ Dependencies |
+
+
+
+ {projects
+ .sort((a, b) =>
+ emoji.strip(a.name).localeCompare(emoji.strip(b.name))
+ )
+ .map((project) => {
+ return (
+
+ | {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