зеркало из https://github.com/mozilla/addons-pm.git
Port to nextjs (#482)
* Initial port to next.js * Re-instate basic test coverage for contrib pages * Remove unnecessary style * Update Procfile * Adopt test directory mirroring to avoid issues with test files in pages * Try start-nginx-solo * Attempt to configure next under nginx * Use custom server * Properly specify socket file * Get rid of API_ROOT in favour of env vars * Add .env defaults file * Allow .env.example to be committed * Fix bug with not redefining API urls based on new params on client-side * Run prettier * Update next and fix tests * Surface API errors on server * Fix bug caused by Next.js incorrectly handling period delimted placeholders * Update dashboards adding stockwell and p3 and general clean-ups * Re-instate redirect http->https * Restore fragement fetcher script and update to use next/env lib * Add comment re: hashing for ghapi * Restore stylelint * Re-instate contrib css * Workaround ENOMEM issues running on circle * Remove unnecessary React imports * Use bob's jsx-a11y config * Condense waitFor * fix eslint config * Fix lint * Remove null default * Add a robots.txt file * Remove mock clearing as it's automatic * Update nginx config with includes + caching * Try caching to /tmp dir on heroku * Fix missing React import * Enable nginx caching for public/static files served under /static * Fix nginx config * Use JSON.stringify * Use eslint-plugin-amo and fix lint * Remove lint options that no longer conflict with prettier * toBe -> toEqual * Better descriptions for redirect tests * Stockwell -> STW * Add NEXT_TELEMETRY_DISABLED=1 to .env * Remove commented console statement * Run prettier * Move test directories * Dashboard counts for AMO should be 1 or higher * Don't override constribution assignment * Consolidate assignment * Add space after 'Updated' * Add nprogress * Re-instate inline styles in CSP * Fix links on homepage
This commit is contained in:
Родитель
3379275509
Коммит
f22ddf49b0
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
# DO NOT INCLUDE SECRETS IN THIS FILE!
|
||||
# This file is committed and should contain defaults only.
|
||||
|
||||
HOSTNAME=localhost
|
||||
PORT=3000
|
||||
API_HOST=http://$HOSTNAME:$PORT
|
||||
|
||||
NEXT_TELEMETRY_DISABLED=1
|
39
.eslintrc
39
.eslintrc
|
@ -1,6 +1,41 @@
|
|||
{
|
||||
"extends": "react-app",
|
||||
"extends": [
|
||||
"amo",
|
||||
"plugin:amo/recommended"
|
||||
],
|
||||
"env": {
|
||||
"jest/globals": true
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"jest"
|
||||
],
|
||||
"globals": {
|
||||
"fetch": true
|
||||
},
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": "warn",
|
||||
// These rules are not compatible with Prettier.
|
||||
"indent": "off",
|
||||
"operator-linebreak": "off",
|
||||
"react/jsx-one-expression-per-line": "off",
|
||||
// Modify rules.
|
||||
"jsx-a11y/anchor-is-valid": [
|
||||
"error",
|
||||
{
|
||||
"components": ["Link"],
|
||||
"specialLink": ["hrefLeft", "hrefRight"],
|
||||
"aspects": ["invalidHref", "preferButton"]
|
||||
}
|
||||
],
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/prop-types": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/extensions": "off",
|
||||
"operator-assignment": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-nested-ternary": "off",
|
||||
// This can be off as Next imports React.
|
||||
"react/react-in-jsx-scope": "off",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +1,36 @@
|
|||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.env
|
||||
.env.example
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
/config/
|
||||
/coverage/
|
||||
Procfile
|
||||
# white-list files we want to process
|
||||
# Allow files we want to process
|
||||
!*.js
|
||||
!*.md
|
||||
!*.scss
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
"arrowParens": "always",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"proseWrap": "never"
|
||||
"proseWrap": "never",
|
||||
"quoteProps": "preserve"
|
||||
}
|
||||
|
|
2
Procfile
2
Procfile
|
@ -1 +1 @@
|
|||
web: bin/start-nginx yarn start-server
|
||||
web: bin/start-nginx node bin/server.js
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
// Magically require any env variables defined in a local .env file.
|
||||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadEnvConfig } = require('@next/env');
|
||||
|
||||
// Require env variables.
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
loadEnvConfig(path.join(__dirname, '../'), dev);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (process.env.GH_TOKEN) {
|
||||
|
@ -14,7 +18,7 @@ if (process.env.GH_TOKEN) {
|
|||
|
||||
fetch(`https://api.github.com/graphql`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
variables: {},
|
||||
query: `
|
||||
|
@ -40,12 +44,14 @@ fetch(`https://api.github.com/graphql`, {
|
|||
);
|
||||
result.data.__schema.types = filteredData;
|
||||
fs.writeFile(
|
||||
path.join(__dirname, '../src/server/fragmentTypes.json'),
|
||||
path.join(__dirname, '../lib/fragmentTypes.json'),
|
||||
JSON.stringify(result.data),
|
||||
(err) => {
|
||||
if (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error writing fragmentTypes file', err);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Fragment types successfully extracted!');
|
||||
}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const fs = require('fs');
|
||||
|
||||
const next = require('next');
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
createServer((req, res) => {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
}).listen('/tmp/nginx.socket', (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Add-ons PM NextJS Server is running');
|
||||
fs.openSync('/tmp/app-initialized', 'w');
|
||||
});
|
||||
});
|
|
@ -1,45 +1,38 @@
|
|||
import React from 'react';
|
||||
import { oneLineTrim } from 'common-tags';
|
||||
|
||||
import DashCount from './DashCount';
|
||||
|
||||
export default function AMODashCount(props) {
|
||||
const repo = props.repo.replace(/_/g, '-');
|
||||
let warning = false;
|
||||
let warningLimit;
|
||||
let issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
|
||||
utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen`;
|
||||
|
||||
if (props.title.includes('total open issues')) {
|
||||
}
|
||||
if (props.title.includes('untriaged')) {
|
||||
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
|
||||
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20-label%3A%22priority%3A%20p1%22%20
|
||||
-label%3A%22priority%3A%20p2%22%20-label%3A%22priority%3A%20p3%22%20
|
||||
-label%3A%22priority%3A%20p4%22%20-label%3A%22priority%3A%20p5%22`;
|
||||
|
||||
if (props.count > 15) {
|
||||
warning = true;
|
||||
}
|
||||
warningLimit = 15;
|
||||
}
|
||||
if (props.title.includes('p1')) {
|
||||
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
|
||||
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p1%22`;
|
||||
if (props.count > 0) {
|
||||
warning = true;
|
||||
}
|
||||
warningLimit = 1;
|
||||
}
|
||||
if (props.title.includes('p2')) {
|
||||
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
|
||||
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p2%22`;
|
||||
if (props.count > 0) {
|
||||
warning = true;
|
||||
}
|
||||
warningLimit = 1;
|
||||
}
|
||||
if (props.title.includes('p3')) {
|
||||
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
|
||||
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p3%22`;
|
||||
warningLimit = undefined;
|
||||
}
|
||||
if (props.title.includes('open prs')) {
|
||||
issuesLink = `https://github.com/mozilla/${repo}/pulls?q=is%3Apr+is%3Aopen`;
|
||||
if (props.count > 10) {
|
||||
warning = true;
|
||||
}
|
||||
warningLimit = 10;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -47,7 +40,7 @@ export default function AMODashCount(props) {
|
|||
title={props.title}
|
||||
key={props.repo + props.title}
|
||||
link={issuesLink}
|
||||
warning={warning}
|
||||
warningLimit={warningLimit}
|
||||
count={props.count}
|
||||
/>
|
||||
);
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import AMODashCount from './AMODashCount';
|
||||
import DashCountGroup from './DashCountGroup';
|
||||
|
||||
|
@ -23,8 +22,8 @@ export default function AMODashCountGroup(props) {
|
|||
}),
|
||||
);
|
||||
|
||||
Object.keys(issueCounts).forEach((count, index) => {
|
||||
const totalCount = issueCounts[count].totalCount;
|
||||
Object.keys(issueCounts).forEach((count) => {
|
||||
const { totalCount } = issueCounts[count];
|
||||
const title = count.replace('_', ' ');
|
||||
if (
|
||||
!count.startsWith('__') &&
|
|
@ -0,0 +1,36 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import PropTypes from 'prop-types';
|
||||
import Link from 'next/link';
|
||||
import React, { Children } from 'react';
|
||||
|
||||
const ActiveLink = ({
|
||||
children,
|
||||
activeClassName = 'active',
|
||||
...props
|
||||
} = {}) => {
|
||||
const { asPath } = useRouter();
|
||||
const child = Children.only(children);
|
||||
const childClassName = child.props.className || '';
|
||||
|
||||
// pages/index.js will be matched via props.href
|
||||
// pages/about.js will be matched via props.href
|
||||
// pages/[slug].js will be matched via props.as
|
||||
const className =
|
||||
asPath === props.href || asPath === props.as
|
||||
? `${childClassName} ${activeClassName}`.trim()
|
||||
: childClassName;
|
||||
|
||||
return (
|
||||
<Link {...props}>
|
||||
{React.cloneElement(child, {
|
||||
className: className || null,
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveLink.propTypes = {
|
||||
activeClassName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ActiveLink;
|
|
@ -0,0 +1,191 @@
|
|||
import { Helmet } from 'react-helmet';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import { AlertIcon, LinkIcon } from '@primer/octicons-react';
|
||||
import { dateSort, numericSort, sortData } from 'lib/utils/sort';
|
||||
import YesNoBool from 'components/YesNoBool';
|
||||
import HeaderLink from 'components/HeaderLink';
|
||||
import ActiveLink from 'components/ActiveLink';
|
||||
|
||||
// These views display assignment columns.
|
||||
const sortConfig = {
|
||||
priority: {},
|
||||
title: {},
|
||||
repo: {},
|
||||
assigned: {
|
||||
sortFunc: numericSort,
|
||||
},
|
||||
mentorAssigned: {
|
||||
sortFunc: numericSort,
|
||||
},
|
||||
updatedAt: {
|
||||
sortFunc: dateSort,
|
||||
},
|
||||
};
|
||||
|
||||
function renderRows({ data, hasAssignments }) {
|
||||
const rows = [];
|
||||
const colSpan = 6;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>Loading...</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>
|
||||
<div className="not-found">
|
||||
<p>
|
||||
No issues found! Time to deploy the team to find some quality
|
||||
bugs!
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const issue = data[i];
|
||||
|
||||
rows.push(
|
||||
<tr key={`issue-${i}`}>
|
||||
<td>
|
||||
<span className={issue.priority || 'unprioritized'}>
|
||||
{issue.priority ? issue.priority.toUpperCase() : <AlertIcon />}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href={issue.url} target="_blank" rel="noopener noreferrer">
|
||||
<strong>#{issue.number}:</strong> {issue.title}{' '}
|
||||
<LinkIcon verticalAlign="middle" />
|
||||
</a>
|
||||
</td>
|
||||
<td>{issue.repository.name.replace('addons-', '')}</td>
|
||||
{hasAssignments ? (
|
||||
<td className="centered">
|
||||
<YesNoBool
|
||||
bool={issue.assigned}
|
||||
extraClasses={{
|
||||
yes: ['contributor'],
|
||||
no: ['contributor', 'not-assigned'],
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
{hasAssignments ? (
|
||||
<td className="centered">
|
||||
<YesNoBool
|
||||
bool={issue.mentorAssigned}
|
||||
extraClasses={{
|
||||
yes: ['mentor'],
|
||||
no: ['mentor', 'not-assigned'],
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
<td>
|
||||
<TimeAgo date={issue.updatedAt} />
|
||||
</td>
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const Contrib = (props) => {
|
||||
const router = useRouter();
|
||||
const { dir, sort } = router.query;
|
||||
const { contribData, hasAssignments } = props;
|
||||
|
||||
let data = contribData;
|
||||
if (sort) {
|
||||
data = sortData({ data, columnKey: sort, direction: dir, sortConfig });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Contrib">
|
||||
<Helmet>
|
||||
<title>Contributions</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<ActiveLink
|
||||
activeClassName="active"
|
||||
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="mgfb">Maybe Good First Bugs</Nav.Link>
|
||||
</ActiveLink>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<ActiveLink
|
||||
activeClassName="active"
|
||||
href="/contrib/good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="gfb">Good First Bugs</Nav.Link>
|
||||
</ActiveLink>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<ActiveLink
|
||||
activeClassName="active"
|
||||
href="/contrib/contrib-welcome/?dir=desc&sort=updatedAt"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="cw">Contrib Welcome</Nav.Link>
|
||||
</ActiveLink>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Container as="main" bg="light">
|
||||
<Table responsive hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<HeaderLink columnKey="priority" linkText="Priority" />
|
||||
</th>
|
||||
<th>
|
||||
<HeaderLink columnKey="title" linkText="Issue" />
|
||||
</th>
|
||||
<th className="repo">
|
||||
<HeaderLink columnKey="repo" linkText="Repo" />
|
||||
</th>
|
||||
{hasAssignments ? (
|
||||
<th>
|
||||
<HeaderLink columnKey="assigned" linkText="Assigned?" />
|
||||
</th>
|
||||
) : null}
|
||||
{hasAssignments ? (
|
||||
<th>
|
||||
<HeaderLink
|
||||
columnKey="mentorAssigned"
|
||||
linkText="Has Mentor?"
|
||||
/>
|
||||
</th>
|
||||
) : null}
|
||||
<th className="last-updated">
|
||||
<HeaderLink columnKey="updatedAt" linkText="Last Update" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{renderRows({ data, hasAssignments })}</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contrib;
|
|
@ -0,0 +1,9 @@
|
|||
import { Card } from 'react-bootstrap';
|
||||
|
||||
export default function DashBlank() {
|
||||
return (
|
||||
<Card bg="dark" text="white">
|
||||
<Card.Body />
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,12 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Card } from 'react-bootstrap';
|
||||
|
||||
import './DashCount.scss';
|
||||
|
||||
export default function DashCount(props) {
|
||||
let extraTitle = '';
|
||||
if (typeof props.warningLimit !== 'undefined') {
|
||||
extraTitle = ` (Warning Threshold: count >= ${props.warningLimit})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg="dark"
|
||||
|
@ -14,20 +15,30 @@ export default function DashCount(props) {
|
|||
href={props.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${props.title}${extraTitle}`}
|
||||
>
|
||||
<Card.Header>{props.title.toUpperCase()}</Card.Header>
|
||||
<Card.Body>
|
||||
<div
|
||||
data-testid="dashcount-svg-wrapper"
|
||||
className={classNames({
|
||||
outer: true,
|
||||
warning: props.warning,
|
||||
warning:
|
||||
typeof props.warningLimit !== 'undefined' &&
|
||||
props.count >= props.warningLimit,
|
||||
total: props.title.includes('total open'),
|
||||
})}
|
||||
>
|
||||
<svg preserveAspectRatio="xMinYMin meet">
|
||||
<g>
|
||||
<circle r="30%" cx="50%" cy="50%" className="circle-back" />
|
||||
<text x="50%" y="50%" textAnchor="middle" dy="0.3em">
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dy="0.3em"
|
||||
data-testid="dashcount-count"
|
||||
>
|
||||
{props.count}
|
||||
</text>
|
||||
</g>
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Card } from 'react-bootstrap';
|
||||
|
||||
export default function DashCountGroup(props) {
|
||||
|
@ -6,11 +5,16 @@ export default function DashCountGroup(props) {
|
|||
<div
|
||||
className={props.className ? `card-grp ${props.className}` : 'card-grp'}
|
||||
key={props.key}
|
||||
data-testid="dashcountgroup"
|
||||
>
|
||||
<Card bg="dark" text="white" className="repo-card">
|
||||
<Card.Header>{props.title}</Card.Header>
|
||||
<Card bg="dark" text="white" className="title-card">
|
||||
<Card.Header data-testid="dashcountgroup-title">
|
||||
{props.title}
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Text>{props.description}</Card.Text>
|
||||
<Card.Text data-testid="dashcountgroup-desc">
|
||||
{props.description}
|
||||
</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
{props.children}
|
|
@ -0,0 +1,24 @@
|
|||
import Link from 'next/link';
|
||||
import { Image } from 'react-bootstrap';
|
||||
|
||||
export default function Engineer(props) {
|
||||
const { member, year, quarter } = props;
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${year}/${quarter}/?engineer=${encodeURIComponent(
|
||||
member.login.toLowerCase(),
|
||||
)}`}
|
||||
>
|
||||
<a>
|
||||
<Image
|
||||
src={member.avatarUrl}
|
||||
title={member.login}
|
||||
alt={member.login}
|
||||
roundedCircle
|
||||
className="float-right eng-image"
|
||||
height="35"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function HeaderLink(props) {
|
||||
const router = useRouter();
|
||||
const { columnKey, linkText } = props;
|
||||
const { sort, dir } = router.query;
|
||||
const classDir = dir === 'asc' ? 'asc' : 'desc';
|
||||
let linkDir = 'desc';
|
||||
let className = 'sort-direction';
|
||||
if (sort === columnKey) {
|
||||
linkDir = dir === 'desc' ? 'asc' : 'desc';
|
||||
className = `${className} ${classDir}`;
|
||||
}
|
||||
|
||||
const query = {
|
||||
// Keep existing query params.
|
||||
...router.query,
|
||||
// Override ones related to sort.
|
||||
dir: linkDir,
|
||||
sort: columnKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={{ pathname: router.pathname, query }} passHref>
|
||||
<a className={className}>{linkText}</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export default function YesNoBool(props) {
|
||||
const { bool, extraClasses } = props;
|
||||
const yesOrNo = bool === true ? 'yes' : 'no';
|
||||
let classNames = [yesOrNo];
|
||||
if (extraClasses && extraClasses[yesOrNo]) {
|
||||
classNames = [...classNames, ...extraClasses[yesOrNo]];
|
||||
}
|
||||
return <span className={classNames.join(' ')}>{yesOrNo.toUpperCase()}</span>;
|
||||
}
|
|
@ -16,8 +16,8 @@ http {
|
|||
server_tokens off;
|
||||
|
||||
log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id';
|
||||
access_log logs/nginx/access.log l2met;
|
||||
error_log logs/nginx/error.log;
|
||||
access_log <%= ENV['NGINX_ACCESS_LOG_PATH'] || 'logs/nginx/access.log' %> l2met;
|
||||
error_log <%= ENV['NGINX_ERROR_LOG_PATH'] || 'logs/nginx/error.log' %>;
|
||||
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
@ -26,7 +26,9 @@ http {
|
|||
# Must read the body in 5 seconds.
|
||||
client_body_timeout 5;
|
||||
|
||||
upstream node_api_server {
|
||||
proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
|
||||
|
||||
upstream nextjs_upstream {
|
||||
server unix:/tmp/nginx.socket fail_timeout=0;
|
||||
}
|
||||
|
||||
|
@ -37,45 +39,62 @@ http {
|
|||
port_in_redirect off;
|
||||
|
||||
more_clear_headers 'X-Powered-By';
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security max-age=31536000 always;
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'none';
|
||||
object-src 'none';
|
||||
connect-src 'self';
|
||||
font-src 'self';
|
||||
script-src 'self';
|
||||
img-src 'self' https://*.githubusercontent.com/u/;
|
||||
style-src 'self' 'unsafe-inline'" always;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
if ($http_x_forwarded_proto != 'https') {
|
||||
rewrite ^ https://$host$request_uri? permanent;
|
||||
}
|
||||
|
||||
location / {
|
||||
root build/;
|
||||
try_files $uri /index.html =404;
|
||||
# A default restrictive CSP that should always be overriden by location blocks.
|
||||
include sec-headers-base.conf;
|
||||
|
||||
# All the JS / CSS served by next.
|
||||
location /_next/static {
|
||||
# Next.js serves far-futures expires itself.
|
||||
# This caching will have nginx serve statics (after the first request)
|
||||
# rather than hitting the app-server.
|
||||
proxy_cache STATIC;
|
||||
proxy_pass http://nextjs_upstream;
|
||||
|
||||
# For testing cache - remove before deploying to production
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
# Full sec headers so error pages work.
|
||||
include sec-headers.conf;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
# Serves static files added to public/static
|
||||
location /static {
|
||||
proxy_cache STATIC;
|
||||
proxy_ignore_headers Cache-Control;
|
||||
proxy_cache_valid 60m;
|
||||
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
# Full sec headers so error pages work.
|
||||
include sec-headers.conf;
|
||||
|
||||
proxy_pass http://nextjs_upstream;
|
||||
}
|
||||
|
||||
location /api {
|
||||
# Full sec headers so error pages work.
|
||||
include sec-headers.conf;
|
||||
proxy_pass http://nextjs_upstream;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security max-age=31536000 always;
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'none';
|
||||
base-uri 'none';
|
||||
form-action 'none';
|
||||
object-src 'none'" always;
|
||||
include sec-headers.conf;
|
||||
|
||||
proxy_pass http://node_api_server;
|
||||
proxy_pass http://nextjs_upstream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Strict-Transport-Security max-age=31536000 always;
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'none';
|
||||
base-uri 'none';
|
||||
form-action 'none';
|
||||
object-src 'none'" always;
|
||||
add_header referrer-policy no-referrer-when-downgrade;
|
|
@ -0,0 +1,14 @@
|
|||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Strict-Transport-Security max-age=31536000 always;
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'none';
|
||||
object-src 'none';
|
||||
connect-src 'self';
|
||||
font-src 'self';
|
||||
script-src 'self';
|
||||
img-src 'self' https://*.githubusercontent.com/u/;
|
||||
style-src 'self' 'unsafe-inline'" always;
|
||||
add_header referrer-policy no-referrer-when-downgrade;
|
|
@ -0,0 +1,27 @@
|
|||
// Jest.config.js
|
||||
module.exports = {
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
collectCoverageFrom: ['**/*.{js,jsx}'],
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: '.coverage',
|
||||
coveragePathIgnorePatterns: [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/fixtures/',
|
||||
'<rootDir>/.next/',
|
||||
'<rootDir>/.coverage/',
|
||||
'<rootDir>/jest.config.js',
|
||||
'<rootDir>/jest.setup.js',
|
||||
'<rootDir>/next.config.js',
|
||||
],
|
||||
// Module lookup ordering.
|
||||
moduleDirectories: ['<rootDir>', 'node_modules'],
|
||||
// A list of paths to modules that run some code to configure or set up the testing
|
||||
// framework before each test
|
||||
moduleNameMapper: {
|
||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||
'<rootDir>/tests/mocks/fileMock.js',
|
||||
'\\.(scss|css)$': '<rootDir>/tests/mocks/styleMock.js',
|
||||
},
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
// Jest.setup.js
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
// Set __NEXT_TRAILING_SLASH to configure trailing slashes for tests
|
||||
// Temp workaround for https://github.com/vercel/next.js/issues/16094
|
||||
const { trailingSlash } = require('./next.config');
|
||||
|
||||
process.env = { ...process.env, __NEXT_TRAILING_SLASH: trailingSlash };
|
||||
|
||||
// Turn off console for tests.
|
||||
jest.spyOn(global.console, 'log').mockImplementation(() => jest.fn());
|
||||
jest.spyOn(global.console, 'debug').mockImplementation(() => jest.fn());
|
||||
|
||||
global.fetch = require('fetch-mock');
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import queryString from 'query-string';
|
||||
|
||||
import serverSWR from './serverSWR';
|
||||
|
||||
export const baseWEBURL = 'https://bugzilla.mozilla.org/buglist.cgi';
|
||||
const baseAPIURL = 'https://bugzilla.mozilla.org/rest/bug';
|
||||
|
||||
const needsInfoParams = {
|
||||
include_fields: 'id',
|
||||
f1: 'flagtypes.name',
|
||||
f2: 'requestees.login_name',
|
||||
o1: 'casesubstring',
|
||||
o2: 'equals',
|
||||
v1: 'needinfo?',
|
||||
v2: null, // Placeholder for the user email.
|
||||
};
|
||||
|
||||
const whiteboardTagParams = {
|
||||
status_whiteboard_type: 'allwordssubstr',
|
||||
status_whiteboard: null, // Placeholder for the whiteboard tag to look for.
|
||||
};
|
||||
|
||||
const webExtOnlyParams = {
|
||||
component: [
|
||||
'Add-ons Manager',
|
||||
'Android',
|
||||
'Compatibility',
|
||||
'Developer Outreach',
|
||||
'Developer Tools',
|
||||
'Experiments',
|
||||
'Frontend',
|
||||
'General',
|
||||
'Request Handling',
|
||||
'Storage',
|
||||
'Themes',
|
||||
'Untriaged',
|
||||
],
|
||||
product: ['Toolkit', 'WebExtensions'],
|
||||
};
|
||||
|
||||
const openBugParams = {
|
||||
resolution: '---',
|
||||
bug_status: ['ASSIGNED', 'NEW', 'REOPENED', 'UNCONFIRMED'],
|
||||
};
|
||||
|
||||
export function fetchIssueCount({ priority, product, bug_severity } = {}) {
|
||||
const params = {
|
||||
product,
|
||||
priority,
|
||||
bug_severity,
|
||||
count_only: true,
|
||||
limit: 0,
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (params.bug_priority && params.bug_severity) {
|
||||
throw new Error('Query only severity or priority independently');
|
||||
}
|
||||
|
||||
if (bug_severity) {
|
||||
delete params.priority;
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
delete params.bug_severity;
|
||||
}
|
||||
|
||||
if (product === 'Toolkit') {
|
||||
params.component = 'Add-ons Manager';
|
||||
} else if (product === 'Firefox') {
|
||||
params.component = 'Extension Compatibility';
|
||||
}
|
||||
|
||||
const apiURL = `${baseAPIURL}?${queryString.stringify({
|
||||
...params,
|
||||
...openBugParams,
|
||||
})}`;
|
||||
const webParams = { ...params, ...openBugParams };
|
||||
delete webParams.count_only;
|
||||
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
|
||||
return serverSWR(apiURL, async () => {
|
||||
const res = await fetch(apiURL, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
return { count: json.bug_count, url: webURL };
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchNeedInfo(email) {
|
||||
const apiParams = { ...needsInfoParams, ...webExtOnlyParams };
|
||||
apiParams.v2 = email;
|
||||
|
||||
const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`;
|
||||
return serverSWR(apiURL, async () => {
|
||||
const result = await fetch(apiURL, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return result.json();
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchWhiteboardTag(whiteboardTag) {
|
||||
const apiParams = {
|
||||
...whiteboardTagParams,
|
||||
...webExtOnlyParams,
|
||||
...openBugParams,
|
||||
status_whiteboard: whiteboardTag,
|
||||
count_only: true,
|
||||
};
|
||||
|
||||
const webParams = { ...apiParams };
|
||||
delete webParams.count_only;
|
||||
|
||||
const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`;
|
||||
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
|
||||
|
||||
return serverSWR(apiURL, async () => {
|
||||
const result = await fetch(apiURL, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const json = await result.json();
|
||||
return { count: json.bug_count, url: webURL };
|
||||
});
|
||||
}
|
|
@ -1,16 +1,16 @@
|
|||
const validYears = ['2017', '2018', '2019', '2020', '2021'];
|
||||
|
||||
module.exports = {
|
||||
validYears: validYears,
|
||||
validYears,
|
||||
validYearRX: new RegExp(`^(?:${validYears.join('|')})$`),
|
||||
validMilestoneRX: new RegExp(
|
||||
`^(?:${validYears.join(
|
||||
`^(?<year>${validYears.join(
|
||||
'|',
|
||||
)})\\.(?:0[1-9]|1[0-2])\\.(?:0[1-9]|[1-2]\\d|3[0-1])$`,
|
||||
)})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[1-2]\\d|3[0-1])$`,
|
||||
),
|
||||
validQuarterRX: /^Q[1-4]$/,
|
||||
// This defined what team members projects can be filtered by since projects don't have an official
|
||||
// assignment.
|
||||
// This defined what team members projects can be filtered by since projects don't have
|
||||
// an official assignment.
|
||||
// It should contain anyone who owned an add-ons project past and present.
|
||||
// Note: Removing people no longer in the team will prevent old projects related to them
|
||||
// being accessible by URL.
|
||||
|
@ -26,10 +26,6 @@ module.exports = {
|
|||
'willdurand',
|
||||
'xlisachan',
|
||||
],
|
||||
API_ROOT:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? '/api'
|
||||
: 'http://localhost:3000/api',
|
||||
colors: {
|
||||
blocked: '#ffa500',
|
||||
closed: '#98ff98',
|
||||
|
@ -53,4 +49,30 @@ module.exports = {
|
|||
'state: wontfix',
|
||||
],
|
||||
priorities: ['p1', 'p2', 'p3', 'p4', 'p5'],
|
||||
contribRepos: [
|
||||
'mozilla/addons',
|
||||
'mozilla/addons-code-manager',
|
||||
'mozilla/addons-server',
|
||||
'mozilla/addons-frontend',
|
||||
'mozilla/addons-linter',
|
||||
'mozilla/dispensary',
|
||||
'mozilla/extension-workshop',
|
||||
'mozilla/sign-addon',
|
||||
'mozilla/web-ext',
|
||||
'mozilla/webextension-polyfill',
|
||||
'mozilla/FirefoxColor',
|
||||
],
|
||||
bugzilla: {
|
||||
priorities: ['--', 'P1', 'P2', 'P3', 'P4', 'P5'],
|
||||
severities: ['normal', '--', 'N/A', 'S1', 'S2', 'S3', 'S4'],
|
||||
products: ['Toolkit', 'WebExtensions', 'Firefox'],
|
||||
whiteboardTags: [
|
||||
'addons-ux',
|
||||
'stockwell disable-recommended',
|
||||
'stockwell fixed',
|
||||
'stockwell disabled',
|
||||
'stockwell needswork:owner',
|
||||
'stockwell infra',
|
||||
],
|
||||
},
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
import { ApolloClient } from 'apollo-client';
|
||||
import { createHttpLink } from 'apollo-link-http';
|
||||
import {
|
||||
InMemoryCache,
|
||||
IntrospectionFragmentMatcher,
|
||||
} from 'apollo-cache-inmemory';
|
||||
import hash from 'object-hash';
|
||||
|
||||
import introspectionQueryResultData from './fragmentTypes.json';
|
||||
import serverSWR from './serverSWR';
|
||||
|
||||
export default function createClient() {
|
||||
const headers = {};
|
||||
/* istanbul ignore next */
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (process.env.GH_TOKEN) {
|
||||
headers.Authorization = `token ${process.env.GH_TOKEN}`;
|
||||
} else {
|
||||
throw new Error('No GH_TOKEN found');
|
||||
}
|
||||
}
|
||||
|
||||
const fragmentMatcher = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData,
|
||||
});
|
||||
|
||||
// For fetches to work correctly we use a new client instance for
|
||||
// each request to avoid stale data.
|
||||
const gqlClient = new ApolloClient({
|
||||
link: createHttpLink({
|
||||
uri: 'https://api.github.com/graphql',
|
||||
headers,
|
||||
}),
|
||||
cache: new InMemoryCache({
|
||||
fragmentMatcher,
|
||||
}),
|
||||
});
|
||||
|
||||
// Client with serverSWR wrapper to carry out in memory caching of the original API response
|
||||
// from githubs GraphQL API.
|
||||
const client = {
|
||||
query: async ({ query, variables }) => {
|
||||
// Create a hash based on the query with variables.
|
||||
const keyHash = hash(
|
||||
{ queryAsString: query.loc.source.body.toString(), variables },
|
||||
{ algorithm: 'sha256' },
|
||||
);
|
||||
return serverSWR(keyHash, async () => {
|
||||
const result = await gqlClient.query({ query, variables });
|
||||
return result;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
* This file provides a fetch that uses swr's cache under the covers.
|
||||
* This enables caching to be handled in a specific way for API calls we're carrying out from the server.
|
||||
* This enables caching to be handled in a specific way for API calls we're carrying
|
||||
* out from the server.
|
||||
*
|
||||
*/
|
||||
|
||||
|
@ -48,9 +49,8 @@ const serverSWR = async (
|
|||
fetchAndCache();
|
||||
}
|
||||
return cachedData.response;
|
||||
} else {
|
||||
return fetchAndCache();
|
||||
}
|
||||
return fetchAndCache();
|
||||
};
|
||||
|
||||
module.exports = serverSWR;
|
|
@ -0,0 +1,31 @@
|
|||
import { hasLabelContainingString } from 'lib/utils';
|
||||
import { priorities } from 'lib/const';
|
||||
|
||||
export function formatContribData(data) {
|
||||
const issues = [];
|
||||
data.forEach((item) => {
|
||||
const issue = {
|
||||
...item.issue,
|
||||
priority: '',
|
||||
assigned: false,
|
||||
mentorAssigned: false,
|
||||
};
|
||||
const labels = issue.labels.nodes || [];
|
||||
priorities.forEach((priority) => {
|
||||
if (hasLabelContainingString(labels, priority)) {
|
||||
issue.priority = priority;
|
||||
}
|
||||
});
|
||||
if (hasLabelContainingString(labels, 'contrib: assigned')) {
|
||||
issue.assigned = true;
|
||||
}
|
||||
if (hasLabelContainingString(labels, 'contrib: mentor assigned')) {
|
||||
issue.mentorAssigned = true;
|
||||
}
|
||||
if (issue.repository && issue.repository.name) {
|
||||
issue.repo = issue.repository.name;
|
||||
}
|
||||
issues.push(issue);
|
||||
});
|
||||
return issues;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import DOMPurifyLib from 'dompurify';
|
||||
import queryString from 'query-string';
|
||||
|
||||
// Create an DOMPurify instance in a universal way.
|
||||
let DOMPurify;
|
||||
if (typeof window === 'undefined') {
|
||||
// eslint-disable-next-line global-require
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { window } = new JSDOM('<!DOCTYPE html>');
|
||||
DOMPurify = DOMPurifyLib(window);
|
||||
} else {
|
||||
DOMPurify = DOMPurifyLib;
|
||||
}
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
export const { sanitize } = DOMPurify;
|
||||
|
||||
export function hasLabel(issueLabels, labelOrLabelList) {
|
||||
const labels = issueLabels || [];
|
||||
if (Array.isArray(labelOrLabelList)) {
|
||||
return labels.some((item) => labelOrLabelList.includes(item.name));
|
||||
}
|
||||
return !!labels.find((label) => label.name === labelOrLabelList);
|
||||
}
|
||||
|
||||
export function hasLabelContainingString(issueLabels, string) {
|
||||
const labels = issueLabels || [];
|
||||
const rx = new RegExp(string);
|
||||
return !!labels.find((label) => rx.test(label.name));
|
||||
}
|
||||
|
||||
export function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
export function colourIsLight(hex) {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
// Counting the perceptive luminance
|
||||
// human eye favors green color...
|
||||
const a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return a < 0.5;
|
||||
}
|
||||
|
||||
export function getApiURL(path, queryParams) {
|
||||
if (!path.startsWith('/api')) {
|
||||
throw new Error(`Path should start with '/api'`);
|
||||
}
|
||||
const host = process.env.API_HOST || '';
|
||||
let apiUrl = `${host}${path}`;
|
||||
if (queryParams) {
|
||||
apiUrl = `${apiUrl}?${queryString.stringify(queryParams)}`;
|
||||
}
|
||||
return apiUrl;
|
||||
}
|
|
@ -0,0 +1,215 @@
|
|||
import { oneLineTrim } from 'common-tags';
|
||||
import { colors, priorities } from 'lib/const';
|
||||
import { colourIsLight, hasLabel, hasLabelContainingString } from 'lib/utils';
|
||||
|
||||
/*
|
||||
* This function should return the next nearest release
|
||||
* date including if the release date is today.
|
||||
* dayOfWeek: Sunday is 0, Monday is 1 etc...
|
||||
*/
|
||||
export function getNextMilestone({
|
||||
dayOfWeek = 4,
|
||||
startDate = new Date(),
|
||||
} = {}) {
|
||||
if (startDate.getDay() === dayOfWeek) {
|
||||
return startDate;
|
||||
}
|
||||
const resultDate = new Date(startDate.getTime());
|
||||
resultDate.setDate(
|
||||
startDate.getDate() + ((7 + dayOfWeek - startDate.getDay() - 1) % 7) + 1,
|
||||
);
|
||||
return resultDate;
|
||||
}
|
||||
|
||||
/*
|
||||
* Formats a date object into a milestone format YYYY.MM.DD
|
||||
* Handles zero filling so 2019.1.1 will be 2019.01.01
|
||||
*/
|
||||
export function formatDateToMilestone(date) {
|
||||
return oneLineTrim`${date.getFullYear()}-
|
||||
${(date.getMonth() + 1).toString().padStart(2, '0')}-
|
||||
${date.getDate().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Computes an object with pagination data based on starting day of week and defaulting
|
||||
* to the current date.
|
||||
*
|
||||
*/
|
||||
export function getMilestonePagination({
|
||||
dayOfWeek = 4,
|
||||
startDate = new Date(),
|
||||
} = {}) {
|
||||
// The nearest release milestone to the starting point.
|
||||
let nextMilestone = getNextMilestone({ dayOfWeek, startDate });
|
||||
const prev = new Date(
|
||||
nextMilestone.getFullYear(),
|
||||
nextMilestone.getMonth(),
|
||||
nextMilestone.getDate() - 7,
|
||||
);
|
||||
|
||||
// Set next Milestone to 7 days time if we're starting on current milestone date already.
|
||||
if (
|
||||
formatDateToMilestone(startDate) === formatDateToMilestone(nextMilestone)
|
||||
) {
|
||||
nextMilestone = new Date(
|
||||
nextMilestone.getFullYear(),
|
||||
nextMilestone.getMonth(),
|
||||
nextMilestone.getDate() + 7,
|
||||
);
|
||||
}
|
||||
|
||||
// The current milestone closest to today.
|
||||
const currentMilestone = getNextMilestone(dayOfWeek);
|
||||
|
||||
return {
|
||||
// The milestone before the startDate.
|
||||
prevFromStart: formatDateToMilestone(prev),
|
||||
// The startDate milestone (might not be a typical release day).
|
||||
start: formatDateToMilestone(startDate),
|
||||
// The milestone after the startDate.
|
||||
nextFromStart: formatDateToMilestone(nextMilestone),
|
||||
// The current closest milestone to today.
|
||||
current: formatDateToMilestone(currentMilestone),
|
||||
};
|
||||
}
|
||||
|
||||
// Set priority if there's a priority label associated with the issue.
|
||||
export function setIssuePriorityProp(issue) {
|
||||
const labels = (issue.labels && issue.labels.nodes) || [];
|
||||
issue.priority = null;
|
||||
priorities.forEach((priority) => {
|
||||
if (hasLabelContainingString(labels, priority)) {
|
||||
issue.priority = priority;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set the repo name directly on the issue.
|
||||
export function setRepoProp(issue) {
|
||||
if (issue.repository && issue.repository.name) {
|
||||
issue.repo = issue.repository.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Update project info,
|
||||
export function setProjectProps(issue) {
|
||||
issue.hasProject = false;
|
||||
if (
|
||||
issue.projectCards &&
|
||||
issue.projectCards.nodes &&
|
||||
issue.projectCards.nodes.length
|
||||
) {
|
||||
issue.hasProject = true;
|
||||
issue.projectUrl = issue.projectCards.nodes[0].project.url;
|
||||
issue.projectName = issue.projectCards.nodes[0].project.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Add assignee prop pointing to the login of the first assignee.
|
||||
export function setAssigneeProp(issue) {
|
||||
const labels = (issue.labels && issue.labels.nodes) || [];
|
||||
issue.isContrib = false;
|
||||
issue.assignee = '00_unassigned';
|
||||
if (issue.assignees.nodes.length) {
|
||||
issue.assignee = issue.assignees.nodes[0].login;
|
||||
} else if (hasLabelContainingString(labels, 'contrib: assigned')) {
|
||||
issue.isContrib = true;
|
||||
issue.assignee = '01_contributor';
|
||||
}
|
||||
}
|
||||
|
||||
export function setReviewerDetails(issue) {
|
||||
issue.reviewers = [];
|
||||
const reviewersListSeen = [];
|
||||
|
||||
if (issue.state === 'CLOSED') {
|
||||
issue.timelineItems.edges.forEach((timelineItem) => {
|
||||
if (!timelineItem.event.source.reviews) {
|
||||
// This is not a pull request item.
|
||||
return;
|
||||
}
|
||||
const { bodyText } = timelineItem.event.source;
|
||||
const issueTestRx = new RegExp(`Fix(?:es)? #${issue.number}`, 'i');
|
||||
|
||||
// Only add the review if the PR contains a `Fixes #num` or `Fix #num` line that
|
||||
// matches the original issue.
|
||||
if (issueTestRx.test(bodyText)) {
|
||||
timelineItem.event.source.reviews.edges.forEach(
|
||||
({ review: { author } }) => {
|
||||
if (!reviewersListSeen.includes(author.login)) {
|
||||
reviewersListSeen.push(author.login);
|
||||
issue.reviewers.push({
|
||||
author,
|
||||
prLink: timelineItem.event.source.permalink,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quick and dirty way to provide a sortable key for reviewers.
|
||||
issue.reviewersNames = '';
|
||||
if (issue.reviewers.length) {
|
||||
issue.reviewersNames = issue.reviewers
|
||||
.map((review) => review.author.login)
|
||||
.join('-');
|
||||
}
|
||||
}
|
||||
|
||||
export function setStateLabels(issue) {
|
||||
const labels = (issue.labels && issue.labels.nodes) || [];
|
||||
// Define current state of the issue.
|
||||
issue.stateLabel = issue.state.toLowerCase();
|
||||
issue.stateLabelColor =
|
||||
issue.state === 'CLOSED' ? colors.closed : colors.open;
|
||||
|
||||
if (issue.state === 'OPEN' && hasLabel(labels, 'state: pull request ready')) {
|
||||
issue.stateLabel = 'PR ready';
|
||||
issue.stateLabelColor = colors.prReady;
|
||||
} else if (issue.state === 'OPEN' && hasLabel(labels, 'state: in progress')) {
|
||||
issue.stateLabel = 'in progress';
|
||||
issue.stateLabelColor = colors.inProgress;
|
||||
} else if (
|
||||
issue.state === 'CLOSED' &&
|
||||
hasLabel(labels, 'state: verified fixed')
|
||||
) {
|
||||
issue.stateLabel = 'verified fixed';
|
||||
issue.stateLabelColor = colors.verified;
|
||||
} else if (issue.state === 'CLOSED' && hasLabel(labels, 'qa: not needed')) {
|
||||
issue.stateLabel = 'closed QA-';
|
||||
issue.stateLabelColor = colors.verified;
|
||||
}
|
||||
|
||||
issue.stateLabelTextColor = colourIsLight(issue.stateLabelColor)
|
||||
? '#000'
|
||||
: '#fff';
|
||||
}
|
||||
|
||||
/*
|
||||
* This function massages the issue data and adds additional properties
|
||||
* to make it easier to display.
|
||||
*/
|
||||
export function formatIssueData(jsonData) {
|
||||
const issues = [];
|
||||
|
||||
if (jsonData.data && jsonData.data.milestone_issues) {
|
||||
const issueData = jsonData.data.milestone_issues.results;
|
||||
|
||||
issueData.forEach((item) => {
|
||||
// Set defaults.
|
||||
const { issue } = item;
|
||||
setIssuePriorityProp(issue);
|
||||
setRepoProp(issue);
|
||||
setProjectProps(issue);
|
||||
setStateLabels(issue);
|
||||
setAssigneeProp(issue);
|
||||
setReviewerDetails(issue);
|
||||
issues.push(issue);
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Defaulting to starting with today's date work out the current year and quarter.
|
||||
*
|
||||
*/
|
||||
export function getCurrentQuarter({ _date } = {}) {
|
||||
const today = _date || new Date();
|
||||
const year = today.getFullYear();
|
||||
const quarter = `Q${Math.floor((today.getMonth() + 3) / 3)}`;
|
||||
return {
|
||||
year,
|
||||
quarter,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the previous quarter and year, from starting with a quarter and year as input.
|
||||
*
|
||||
*/
|
||||
export function getPrevQuarter({ quarter, year } = {}) {
|
||||
if (!quarter || !year) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const numericQuarter = quarter.substr(1);
|
||||
let newQuarter = parseInt(numericQuarter, 10);
|
||||
let newYear = parseInt(year, 10);
|
||||
|
||||
if (newQuarter > 1) {
|
||||
newQuarter = newQuarter - 1;
|
||||
} else if (newQuarter === 1) {
|
||||
newQuarter = 4;
|
||||
newYear = newYear - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
year: newYear,
|
||||
quarter: `Q${newQuarter}`,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the next quarter and year, starting with a quarter and year as input.
|
||||
*
|
||||
*/
|
||||
export function getNextQuarter({ quarter, year } = {}) {
|
||||
if (!quarter || !year) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const numericQuarter = quarter.substr(1);
|
||||
let newYear = parseInt(year, 10);
|
||||
let newQuarter = parseInt(numericQuarter, 10);
|
||||
|
||||
if (newQuarter < 4) {
|
||||
newQuarter = newQuarter + 1;
|
||||
} else if (newQuarter === 4) {
|
||||
newQuarter = 1;
|
||||
newYear = newYear + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
year: newYear,
|
||||
quarter: `Q${newQuarter}`,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* This is a universal wrapper for DOMParser
|
||||
*/
|
||||
export function getDOMParser() {
|
||||
if (typeof window === 'undefined' && require) {
|
||||
// eslint-disable-next-line global-require
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { DOMParser } = new JSDOM().window;
|
||||
return DOMParser;
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
return window.DOMParser;
|
||||
}
|
||||
|
||||
/*
|
||||
* This function parses the specially formatted HTML we add to projects to
|
||||
* provide additional metadata about the projects.
|
||||
* This is mostly to workaround the lack of features like labels on gh projects.
|
||||
*/
|
||||
export function parseProjectMeta(HTML) {
|
||||
const DParser = getDOMParser();
|
||||
const parser = new DParser();
|
||||
const doc = parser.parseFromString(HTML, 'text/html');
|
||||
const engineers = doc
|
||||
.evaluate(
|
||||
"//details//dl/dt[contains(., 'Engineering')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
)
|
||||
.stringValue.replace(/ ?@/g, '')
|
||||
.split(',');
|
||||
const goalType = doc
|
||||
.evaluate(
|
||||
"//details//dl/dt[contains(., 'Goal Type')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
)
|
||||
.stringValue.toLowerCase();
|
||||
const size = doc.evaluate(
|
||||
"//details//dl/dt[contains(., 'Size')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
).stringValue;
|
||||
const details = doc.querySelector('details');
|
||||
if (details) {
|
||||
// Remove the meta data HTML from the doc.
|
||||
details.parentNode.removeChild(details);
|
||||
}
|
||||
return [{ engineers, goalType, size }, doc.documentElement.outerHTML];
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/* eslint-disable no-console */
|
||||
export function dateSort(key) {
|
||||
return (a, b) => {
|
||||
return new Date(a[key]) - new Date(b[key]);
|
||||
};
|
||||
}
|
||||
|
||||
export function numericSort(key) {
|
||||
return (a, b) => {
|
||||
return a[key] - b[key];
|
||||
};
|
||||
}
|
||||
|
||||
export function alphaSort(key) {
|
||||
return (a, b) => {
|
||||
const strA = a[key].toUpperCase();
|
||||
const strB = b[key].toUpperCase();
|
||||
if (strA < strB) {
|
||||
return -1;
|
||||
}
|
||||
if (strA > strB) {
|
||||
return 1;
|
||||
}
|
||||
// names must be equal
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function sortData({ columnKey, data, direction, sortConfig } = {}) {
|
||||
if (!data) {
|
||||
console.debug('No data yet, bailing');
|
||||
return data;
|
||||
}
|
||||
if (!Object.keys(sortConfig).includes(columnKey)) {
|
||||
console.debug(
|
||||
`"${columnKey}" does not match one of "${Object.keys(sortConfig).join(
|
||||
', ',
|
||||
)}"`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
if (!['desc', 'asc'].includes(direction)) {
|
||||
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
|
||||
return data;
|
||||
}
|
||||
|
||||
const sortFunc = sortConfig[columnKey].sortFunc || alphaSort;
|
||||
const sorted = [].concat(data).sort(sortFunc(columnKey));
|
||||
|
||||
// Reverse for desc.
|
||||
if (direction === 'desc') {
|
||||
sorted.reverse();
|
||||
}
|
||||
return sorted;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
trailingSlash: true,
|
||||
};
|
115
package.json
115
package.json
|
@ -1,74 +1,57 @@
|
|||
{
|
||||
"name": "addons-pm",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mozilla/addons-pm.git"
|
||||
},
|
||||
"author": "Mozilla Add-ons Team",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@githubprimer/octicons-react": "8.5.0",
|
||||
"apollo-boost": "0.4.9",
|
||||
"bootstrap": "4.6.0",
|
||||
"classnames": "2.2.6",
|
||||
"common-tags": "1.8.0",
|
||||
"dompurify": "2.2.6",
|
||||
"dotenv": "8.2.0",
|
||||
"graphql": "15.5.0",
|
||||
"graphql-tag": "2.11.0",
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"query-string": "6.13.8",
|
||||
"react": "16.14.0",
|
||||
"react-bootstrap": "1.4.3",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-router-bootstrap": "0.25.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-scripts": "3.4.4",
|
||||
"react-timeago": "5.2.0",
|
||||
"swr": "0.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start-server": "node src/server/index.js",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --env=jest-environment-jsdom-sixteen",
|
||||
"test-coverage": "npm run test -- --watchAll=false --coverage --collectCoverageFrom=src/client/**/*js --collectCoverageFrom=src/server/**/*js",
|
||||
"test-ci": "npm run test-coverage && codecov",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint src",
|
||||
"heroku-postbuild": "INLINE_RUNTIME_CHUNK=false yarn build",
|
||||
"prettier-full": "prettier --write '**'",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "jest . --watch",
|
||||
"test-ci": "jest . --coverage --maxWorkers=2",
|
||||
"lint": "eslint .",
|
||||
"prettier": "prettier --write '**'",
|
||||
"prettier-ci": "prettier -c '**'",
|
||||
"prettier": "pretty-quick --branch master",
|
||||
"stmux": "stmux -nM -t addons-pm -e 'error,!0 errors,!no errors'",
|
||||
"stylelint": "stylelint --syntax scss **/*.scss",
|
||||
"start": "yarn stmux [ 'yarn test' .. [ 'BROWSER=none yarn react-scripts start' : 'yarn start-server' ]]"
|
||||
"stylelint": "stylelint --syntax scss **/*.scss"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primer/octicons-react": "11.1.0",
|
||||
"apollo-boost": "^0.4.9",
|
||||
"bootstrap": "4.5.3",
|
||||
"classnames": "^2.2.6",
|
||||
"common-tags": "^1.8.0",
|
||||
"dompurify": "^2.2.3",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jsdom": "^16.4.0",
|
||||
"next": "10.0.6",
|
||||
"nprogress": "^0.2.0",
|
||||
"object-hash": "^2.0.3",
|
||||
"query-string": "^6.13.7",
|
||||
"react": "17.0.1",
|
||||
"react-bootstrap": "1.4.0",
|
||||
"react-dom": "17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-timeago": "^5.2.0",
|
||||
"sass": "^1.29.0",
|
||||
"swr": "^0.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "11.2.5",
|
||||
"codecov": "3.8.1",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.6",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest-environment-jsdom-sixteen": "1.0.3",
|
||||
"jest-enzyme": "7.1.2",
|
||||
"mock-express-request": "0.2.2",
|
||||
"mock-express-response": "0.3.0",
|
||||
"node-sass": "4.14.1",
|
||||
"prettier": "2.2.1",
|
||||
"pretty-quick": "3.1.0",
|
||||
"sinon": "9.2.4",
|
||||
"stmux": "1.8.1",
|
||||
"stylelint": "13.9.0",
|
||||
"stylelint-config-standard": "20.0.0",
|
||||
"stylelint-config-suitcss": "15.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
]
|
||||
"@babel/core": "^7.12.9",
|
||||
"@testing-library/jest-dom": "^5.11.6",
|
||||
"@testing-library/react": "^11.2.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"eslint": "^7.15.0",
|
||||
"eslint-config-amo": "^3.9.0",
|
||||
"eslint-plugin-amo": "^1.13.0",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"jest": "^26.6.3",
|
||||
"mock-express-request": "^0.2.2",
|
||||
"mock-express-response": "^0.2.2",
|
||||
"prettier": "^2.2.1",
|
||||
"stylelint": "^13.9.0",
|
||||
"stylelint-config-standard": "^20.0.0",
|
||||
"stylelint-config-suitcss": "^15.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import '../styles/globals.scss';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import { Nav, Navbar } from 'react-bootstrap';
|
||||
import { MarkGithubIcon } from '@primer/octicons-react';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import NProgress from 'nprogress';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
const router = useRouter();
|
||||
React.useEffect(() => {
|
||||
const routeChangeStart = () => {
|
||||
NProgress.start();
|
||||
};
|
||||
const routeChangeComplete = () => {
|
||||
NProgress.done();
|
||||
};
|
||||
|
||||
router.events.on('routeChangeStart', routeChangeStart);
|
||||
router.events.on('routeChangeComplete', routeChangeComplete);
|
||||
router.events.on('routeChangeError', routeChangeComplete);
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', routeChangeStart);
|
||||
router.events.off('routeChangeComplete', routeChangeComplete);
|
||||
router.events.off('routeChangeError', routeChangeComplete);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-testid="app-wrapper">
|
||||
<Helmet defaultTitle="Add-ons PM" titleTemplate="%s - Add-ons PM" />
|
||||
<Navbar bg="dark" variant="dark">
|
||||
<Nav className="mr-auto">
|
||||
<Nav.Item>
|
||||
<Link href="/" passHref>
|
||||
<Nav.Link className="navbar-brand" eventKey={0}>
|
||||
Addons PM
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href="/projects/latest/" passHref>
|
||||
<Nav.Link eventKey={1}>Projects</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href="/milestones/latest/?dir=asc&sort=assignee" passHref>
|
||||
<Nav.Link eventKey={2}>Milestones</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href="/dashboards/amo/" passHref>
|
||||
<Nav.Link eventKey={3}>AMO Dashboard</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href="/dashboards/webext/" passHref>
|
||||
<Nav.Link eventKey={4}>Webext Dashboard</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link
|
||||
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey={5}>Contributions</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
<Nav className="mr-sm-2">
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
data-ref="src"
|
||||
eventKey="src"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/mozilla/addons-pm/"
|
||||
>
|
||||
<MarkGithubIcon
|
||||
verticalAlign="middle"
|
||||
aria-label="View on Github"
|
||||
size="medium"
|
||||
/>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Component {...pageProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
|
@ -0,0 +1,40 @@
|
|||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
static async getInitialProps(...args) {
|
||||
const documentProps = await super.getInitialProps(...args);
|
||||
// see https://github.com/nfl/react-helmet#server-usage for more information
|
||||
// 'head' was occupied by 'renderPage().head', we cannot use it
|
||||
return { ...documentProps, helmet: Helmet.renderStatic() };
|
||||
}
|
||||
|
||||
// should render on <html>
|
||||
get helmetHtmlAttrComponents() {
|
||||
return this.props.helmet.htmlAttributes.toComponent();
|
||||
}
|
||||
|
||||
// should render on <body>
|
||||
get helmetBodyAttrComponents() {
|
||||
return this.props.helmet.bodyAttributes.toComponent();
|
||||
}
|
||||
|
||||
// should render on <head>
|
||||
get helmetHeadComponents() {
|
||||
return Object.keys(this.props.helmet)
|
||||
.filter((el) => el !== 'htmlAttributes' && el !== 'bodyAttributes')
|
||||
.map((el) => this.props.helmet[el].toComponent());
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Html {...this.helmetHtmlAttrComponents}>
|
||||
<Head>{this.helmetHeadComponents}</Head>
|
||||
<body {...this.helmetBodyAttrComponents}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { fetchIssueCount } from 'lib/bzapi';
|
||||
import { bugzilla } from 'lib/const';
|
||||
|
||||
export default async (req, res) => {
|
||||
const requests = [];
|
||||
const combinedData = {};
|
||||
|
||||
for (const product of bugzilla.products) {
|
||||
combinedData[product] = {};
|
||||
|
||||
for (const priority of bugzilla.priorities) {
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
priority,
|
||||
bug_severity: null,
|
||||
}).then((result) => {
|
||||
let priorityLabel;
|
||||
switch (priority) {
|
||||
case '--':
|
||||
priorityLabel = 'default';
|
||||
break;
|
||||
default:
|
||||
priorityLabel = priority.toLowerCase();
|
||||
}
|
||||
combinedData[product][`priority-${priorityLabel}`] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const bug_severity of bugzilla.severities) {
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
bug_severity,
|
||||
priority: null,
|
||||
}).then((result) => {
|
||||
let severityLabel;
|
||||
switch (bug_severity) {
|
||||
case 'N/A':
|
||||
severityLabel = 'not-applicable';
|
||||
break;
|
||||
case '--':
|
||||
severityLabel = 'default';
|
||||
break;
|
||||
default:
|
||||
severityLabel = bug_severity.toLowerCase();
|
||||
}
|
||||
combinedData[product][`severity-${severityLabel}`] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
bug_severity: null,
|
||||
priority: null,
|
||||
}).then((result) => {
|
||||
combinedData[product].total = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests).then(() => {
|
||||
res.json(combinedData);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { fetchNeedInfo, baseWEBURL } from 'lib/bzapi';
|
||||
|
||||
export default async (req, res) => {
|
||||
const requests = [];
|
||||
const combinedData = {};
|
||||
const BZ_USERS = JSON.parse(process.env.BZ_USERS) || {};
|
||||
|
||||
for (const nick in BZ_USERS) {
|
||||
if (Object.prototype.hasOwnProperty.call(BZ_USERS, nick)) {
|
||||
combinedData[nick] = {};
|
||||
requests.push(
|
||||
fetchNeedInfo(BZ_USERS[nick]).then((result) => {
|
||||
combinedData[nick].count = result.bugs.length;
|
||||
if (result.bugs.length) {
|
||||
combinedData[nick].url = `${baseWEBURL}?bug_id=${result.bugs
|
||||
.map((item) => item.id)
|
||||
.join(',')}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(requests).then(() => {
|
||||
res.json(combinedData);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import { fetchWhiteboardTag } from 'lib/bzapi';
|
||||
import { bugzilla } from 'lib/const';
|
||||
|
||||
export default async (req, res) => {
|
||||
const requests = [];
|
||||
const combinedData = {};
|
||||
|
||||
for (const whiteboardTag of bugzilla.whiteboardTags) {
|
||||
combinedData[whiteboardTag] = {};
|
||||
requests.push(
|
||||
fetchWhiteboardTag(whiteboardTag).then((result) => {
|
||||
combinedData[whiteboardTag] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests).then(() => {
|
||||
res.json(combinedData);
|
||||
});
|
||||
};
|
|
@ -1,12 +1,12 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
const { CONTRIB_REPOS } = require('./utils');
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
import { contribRepos } from 'lib/const';
|
||||
|
||||
const contribWelcome = gql`
|
||||
{
|
||||
const query = gql`{
|
||||
contrib_welcome: search(
|
||||
type: ISSUE
|
||||
query: """
|
||||
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
|
||||
${contribRepos.map((n) => `repo:${n}`).join('\n')}
|
||||
label:"contrib: welcome"
|
||||
is:open
|
||||
sort:updated-desc
|
||||
|
@ -37,6 +37,10 @@ const contribWelcome = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
contribWelcome,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query,
|
||||
});
|
||||
res.json(data);
|
||||
};
|
|
@ -1,12 +1,12 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
const { CONTRIB_REPOS } = require('./utils');
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
import { contribRepos } from 'lib/const';
|
||||
|
||||
const goodFirstBugs = gql`
|
||||
{
|
||||
const query = gql`{
|
||||
good_first_bugs: search(
|
||||
type: ISSUE
|
||||
query: """
|
||||
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
|
||||
${contribRepos.map((n) => `repo:${n}`).join('\n')}
|
||||
label:"contrib: good first bug"
|
||||
is:open
|
||||
sort:updated-desc
|
||||
|
@ -37,6 +37,10 @@ const goodFirstBugs = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
goodFirstBugs,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query,
|
||||
});
|
||||
res.json(data);
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
|
||||
const issueCounts = gql`
|
||||
const query = gql`
|
||||
fragment issueCounts on Repository {
|
||||
description
|
||||
total_issues: issues(states: OPEN) {
|
||||
|
@ -24,6 +25,9 @@ const issueCounts = gql`
|
|||
open_p2s: issues(states: OPEN, labels: "priority: p2") {
|
||||
totalCount
|
||||
}
|
||||
open_p3s: issues(states: OPEN, labels: "priority: p3") {
|
||||
totalCount
|
||||
}
|
||||
open_prs: pullRequests(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
|
@ -51,6 +55,10 @@ const issueCounts = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
issueCounts,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query,
|
||||
});
|
||||
res.json(data);
|
||||
};
|
|
@ -1,12 +1,12 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
const { CONTRIB_REPOS } = require('./utils');
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
import { contribRepos } from 'lib/const';
|
||||
|
||||
const maybeGoodFirstBugs = gql`
|
||||
{
|
||||
const query = gql`{
|
||||
maybe_good_first_bugs: search(
|
||||
type: ISSUE
|
||||
query: """
|
||||
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
|
||||
${contribRepos.map((n) => `repo:${n}`).join('\n')}
|
||||
label:"contrib: maybe good first bug"
|
||||
is:open
|
||||
sort:updated-desc
|
||||
|
@ -37,6 +37,10 @@ const maybeGoodFirstBugs = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
maybeGoodFirstBugs,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query,
|
||||
});
|
||||
res.json(data);
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
import { validMilestoneRX } from 'lib/const';
|
||||
|
||||
const milestoneIssues = gql`
|
||||
const query = gql`
|
||||
query getMilestoneIssue($query: String!) {
|
||||
milestone_issues: search(type: ISSUE, query: $query, first: 100) {
|
||||
issueCount
|
||||
|
@ -68,6 +70,30 @@ const milestoneIssues = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
milestoneIssues,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
|
||||
let { milestone } = req.query;
|
||||
// Next.js requires us to use `-` in urls instead of `.` due to
|
||||
// https://github.com/vercel/next.js/issues/16617
|
||||
|
||||
if (!validMilestoneRX.test(milestone)) {
|
||||
res.status(400).json({ error: 'Incorrect milestone format' });
|
||||
} else {
|
||||
milestone = milestone.replace(/-/g, '.');
|
||||
const variables = {
|
||||
query: `repo:mozilla/addons
|
||||
repo:mozilla/addons-server
|
||||
repo:mozilla/addons-frontend
|
||||
repo:mozilla/addons-linter
|
||||
repo:mozilla/addons-code-manager
|
||||
milestone:${milestone}
|
||||
type:issues`,
|
||||
};
|
||||
const data = await client.query({
|
||||
query,
|
||||
variables,
|
||||
});
|
||||
res.json(data);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
import { validYearRX, validQuarterRX } from 'lib/const';
|
||||
|
||||
const query = gql`
|
||||
query getProjects($projectSearch: String!) {
|
||||
organization(login: "mozilla") {
|
||||
projects(first: 100, search: $projectSearch) {
|
||||
nodes {
|
||||
name
|
||||
bodyHTML
|
||||
state
|
||||
url
|
||||
updatedAt
|
||||
columns(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
cards(first: 100, archivedStates: [NOT_ARCHIVED]) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const { year, quarter } = req.query;
|
||||
|
||||
if (!validYearRX.test(year)) {
|
||||
res.status(400).json({ error: 'Incorrect year format' });
|
||||
} else if (!validQuarterRX.test(quarter)) {
|
||||
res.status(400).json({ error: 'Incorrect quarter format' });
|
||||
} else {
|
||||
const projects = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
projectSearch: `Add-ons ${quarter} ${year}`,
|
||||
},
|
||||
});
|
||||
res.json(projects);
|
||||
}
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
const gql = require('graphql-tag').default;
|
||||
import gql from 'graphql-tag';
|
||||
import createClient from 'lib/ghapi';
|
||||
|
||||
const team = gql`
|
||||
const query = gql`
|
||||
query getAddonsTeam {
|
||||
organization(login: "mozilla") {
|
||||
team(slug: "addons-service-developers") {
|
||||
|
@ -25,6 +26,10 @@ const team = gql`
|
|||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
team,
|
||||
export default async (req, res) => {
|
||||
const client = createClient();
|
||||
const team = await client.query({
|
||||
query,
|
||||
});
|
||||
res.json(team);
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import useSWR from 'swr';
|
||||
import Contrib from 'components/Contrib';
|
||||
import Error from 'next/error';
|
||||
import { formatContribData } from 'lib/utils/contrib';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
|
||||
const contribWelcomeURL = getApiURL('/api/gh-contrib-welcome/');
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const res = await fetch(contribWelcomeURL);
|
||||
const errorCode = res.ok ? false : res.status;
|
||||
const contribWelcomeData = await res.json();
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
contribWelcomeData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ContribWelcome = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const { contribWelcomeData: initialContribWelcomeData } = props;
|
||||
const { data: contribData } = useSWR(
|
||||
contribWelcomeURL,
|
||||
async () => {
|
||||
const result = await fetch(contribWelcomeURL);
|
||||
const json = await result.json();
|
||||
return json;
|
||||
},
|
||||
{ initialData: initialContribWelcomeData, refreshInterval: 30000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<Contrib
|
||||
contribData={formatContribData(contribData.data.contrib_welcome.results)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContribWelcome;
|
|
@ -0,0 +1,48 @@
|
|||
import useSWR from 'swr';
|
||||
import Error from 'next/error';
|
||||
import Contrib from 'components/Contrib';
|
||||
import { formatContribData } from 'lib/utils/contrib';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
|
||||
const goodFirstBugsURL = getApiURL('/api/gh-good-first-bugs/');
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const res = await fetch(goodFirstBugsURL);
|
||||
const errorCode = res.ok ? false : res.status;
|
||||
const goodFirstBugsData = await res.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
goodFirstBugsData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const GoodFirstBugs = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const { goodFirstBugsData: initialGoodFirstBugsData } = props;
|
||||
const { data: goodFirstBugsData } = useSWR(
|
||||
goodFirstBugsURL,
|
||||
async () => {
|
||||
const result = await fetch(goodFirstBugsURL);
|
||||
const json = await result.json();
|
||||
return json;
|
||||
},
|
||||
{ initialData: initialGoodFirstBugsData, refreshInterval: 30000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<Contrib
|
||||
contribData={formatContribData(
|
||||
goodFirstBugsData.data.good_first_bugs.results,
|
||||
)}
|
||||
hasAssignments
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoodFirstBugs;
|
|
@ -0,0 +1,48 @@
|
|||
import useSWR from 'swr';
|
||||
import Error from 'next/error';
|
||||
import Contrib from 'components/Contrib';
|
||||
import { formatContribData } from 'lib/utils/contrib';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
|
||||
const maybeGoodFirstBugsURL = getApiURL('/api/gh-maybe-good-first-bugs/');
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const res = await fetch(maybeGoodFirstBugsURL);
|
||||
const errorCode = res.ok ? false : res.status;
|
||||
const maybeGoodFirstBugsData = await res.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
maybeGoodFirstBugsData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MaybeGoodFirstBugs = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const { maybeGoodFirstBugsData: initialMaybeGoodFirstBugsData } = props;
|
||||
const { data: maybeGoodFirstBugsData } = useSWR(
|
||||
maybeGoodFirstBugsURL,
|
||||
async () => {
|
||||
const result = await fetch(maybeGoodFirstBugsURL);
|
||||
const json = await result.json();
|
||||
return json;
|
||||
},
|
||||
{ initialData: initialMaybeGoodFirstBugsData, refreshInterval: 30000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<Contrib
|
||||
contribData={formatContribData(
|
||||
maybeGoodFirstBugsData.data.maybe_good_first_bugs.results,
|
||||
)}
|
||||
hasAssignments
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaybeGoodFirstBugs;
|
|
@ -0,0 +1,76 @@
|
|||
import { Helmet } from 'react-helmet';
|
||||
import Error from 'next/error';
|
||||
import { Container } from 'react-bootstrap';
|
||||
import useSWR from 'swr';
|
||||
import AMODashCountGroup from 'components/AMODashCountGroup';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
|
||||
function renderCounts(issueCountData) {
|
||||
const countGroups = [];
|
||||
Object.keys(issueCountData).forEach((repo) => {
|
||||
countGroups.push(
|
||||
<AMODashCountGroup
|
||||
key={repo}
|
||||
issueCounts={issueCountData[repo]}
|
||||
description={issueCountData[repo].description}
|
||||
repo={repo}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return countGroups;
|
||||
}
|
||||
|
||||
const githubIssueCountsURL = getApiURL('/api/gh-issue-counts/');
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const res = await fetch(githubIssueCountsURL);
|
||||
const errorCode = res.ok ? false : res.status;
|
||||
const amoDashData = await res.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
issueCounts: amoDashData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DashboardAMO = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const { data, error } = useSWR(
|
||||
githubIssueCountsURL,
|
||||
async () => {
|
||||
const result = await fetch(githubIssueCountsURL);
|
||||
return result.json();
|
||||
},
|
||||
{ refreshInterval: 30000, initialData: props.issueCounts },
|
||||
);
|
||||
|
||||
const isLoading = !error && !data;
|
||||
// const isError = error;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Helmet>
|
||||
<title>AMO Dashboard</title>
|
||||
<body className="dash" />
|
||||
</Helmet>
|
||||
<Container as="main">
|
||||
<div className="dash-container">
|
||||
{isLoading ? (
|
||||
<div className="loading">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
renderCounts(data.data)
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardAMO;
|
|
@ -1,16 +1,11 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from 'react-bootstrap';
|
||||
import Error from 'next/error';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { API_ROOT } from '../const';
|
||||
import Client from './Client';
|
||||
import DashCount from './components/DashCount';
|
||||
import DashCountGroup from './components/DashCountGroup';
|
||||
|
||||
import './Dashboard.scss';
|
||||
import DashCount from 'components/DashCount';
|
||||
import DashBlank from 'components/DashBlank';
|
||||
import DashCountGroup from 'components/DashCountGroup';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
|
||||
const meta = {
|
||||
Toolkit: {
|
||||
|
@ -27,14 +22,55 @@ const meta = {
|
|||
},
|
||||
};
|
||||
|
||||
function DashboardWE() {
|
||||
const issueCountURL = getApiURL('/api/bz-issue-counts/');
|
||||
const needInfoURL = getApiURL('/api/bz-need-infos/');
|
||||
const whiteboardURL = getApiURL('/api/bz-whiteboard-tags/');
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const [
|
||||
issueCountsResponse,
|
||||
needInfosResponse,
|
||||
whiteboardResponse,
|
||||
] = await Promise.all([
|
||||
fetch(issueCountURL),
|
||||
fetch(needInfoURL),
|
||||
fetch(whiteboardURL),
|
||||
]);
|
||||
|
||||
const errorCode =
|
||||
issueCountsResponse.ok && needInfosResponse.ok && whiteboardResponse.ok
|
||||
? false
|
||||
: 500;
|
||||
|
||||
const [issueCounts, needInfos, whiteboardTags] = await Promise.all([
|
||||
issueCountsResponse.json(),
|
||||
needInfosResponse.json(),
|
||||
whiteboardResponse.json(),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
issueCounts,
|
||||
needInfos,
|
||||
whiteboardTags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function DashboardWE(props) {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
function getIssueCounts() {
|
||||
const { data, error } = useSWR(
|
||||
`${API_ROOT}/bugzilla-issues-counts/`,
|
||||
issueCountURL,
|
||||
async () => {
|
||||
return await Client.getBugzillaIssueCounts();
|
||||
const result = await fetch(issueCountURL);
|
||||
return result.json();
|
||||
},
|
||||
{ refreshInterval: 30000 },
|
||||
{ refreshInterval: 30000, initialData: props.issueCounts },
|
||||
);
|
||||
return {
|
||||
data,
|
||||
|
@ -45,11 +81,28 @@ function DashboardWE() {
|
|||
|
||||
function getNeedInfos() {
|
||||
const { data, error } = useSWR(
|
||||
`${API_ROOT}/bugzilla-need-infos/`,
|
||||
needInfoURL,
|
||||
async () => {
|
||||
return await Client.getBugzillaNeedInfos();
|
||||
const result = await fetch(needInfoURL);
|
||||
return result.json();
|
||||
},
|
||||
{ refreshInterval: 30000 },
|
||||
{ refreshInterval: 90000, initialData: props.needInfos },
|
||||
);
|
||||
return {
|
||||
data,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
function getWhiteboardTags() {
|
||||
const { data, error } = useSWR(
|
||||
whiteboardURL,
|
||||
async () => {
|
||||
const result = await fetch(whiteboardURL);
|
||||
return result.json();
|
||||
},
|
||||
{ refreshInterval: 90000, initialData: props.whiteboardTags },
|
||||
);
|
||||
return {
|
||||
data,
|
||||
|
@ -60,6 +113,7 @@ function DashboardWE() {
|
|||
|
||||
const needInfos = getNeedInfos();
|
||||
const issueCounts = getIssueCounts();
|
||||
const whiteboardTags = getWhiteboardTags();
|
||||
|
||||
function renderChild({ data, dataKey, component, title, warningLimit }) {
|
||||
const { count, url } = data[dataKey];
|
||||
|
@ -68,24 +122,20 @@ function DashboardWE() {
|
|||
count={count}
|
||||
key={component + title}
|
||||
title={title}
|
||||
warning={warningLimit && count >= warningLimit}
|
||||
warningLimit={warningLimit}
|
||||
link={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChildren(component, data) {
|
||||
const nodes = [];
|
||||
nodes.push(
|
||||
return [
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'total',
|
||||
title: 'total open',
|
||||
warningLimit: null,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'severity-default',
|
||||
|
@ -93,8 +143,6 @@ function DashboardWE() {
|
|||
warningLimit: 15,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'severity-s1',
|
||||
|
@ -102,8 +150,6 @@ function DashboardWE() {
|
|||
warningLimit: 1,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'severity-s2',
|
||||
|
@ -111,8 +157,6 @@ function DashboardWE() {
|
|||
warningLimit: 10,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'priority-p1',
|
||||
|
@ -120,8 +164,6 @@ function DashboardWE() {
|
|||
warningLimit: 10,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'priority-p2',
|
||||
|
@ -129,17 +171,13 @@ function DashboardWE() {
|
|||
warningLimit: 20,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
nodes.push(
|
||||
renderChild({
|
||||
data,
|
||||
dataKey: 'priority-p3',
|
||||
title: 'P3',
|
||||
warningLimit: null,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
return nodes;
|
||||
];
|
||||
}
|
||||
|
||||
function renderCounts() {
|
||||
|
@ -148,7 +186,7 @@ function DashboardWE() {
|
|||
}
|
||||
const countGroups = [];
|
||||
Object.keys(issueCounts.data).forEach((component, index) => {
|
||||
if (meta.hasOwnProperty(component)) {
|
||||
if (Object.prototype.hasOwnProperty.call(meta, component)) {
|
||||
countGroups.push(
|
||||
DashCountGroup({
|
||||
key: index + meta[component].title,
|
||||
|
@ -158,7 +196,8 @@ function DashboardWE() {
|
|||
}),
|
||||
);
|
||||
} else {
|
||||
console.log(`countGroup "${component}: added without meta`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`countGroup "${component}: added without meta`);
|
||||
}
|
||||
});
|
||||
return countGroups;
|
||||
|
@ -166,7 +205,7 @@ function DashboardWE() {
|
|||
|
||||
function renderNeedInfos() {
|
||||
const children = [];
|
||||
Object.keys(needInfos.data).forEach((nick, index) => {
|
||||
Object.keys(needInfos.data).forEach((nick) => {
|
||||
children.push(
|
||||
renderChild({
|
||||
data: needInfos.data,
|
||||
|
@ -178,6 +217,9 @@ function DashboardWE() {
|
|||
);
|
||||
});
|
||||
|
||||
children.push(<DashBlank key="ni-blank-1" />);
|
||||
children.push(<DashBlank key="ni-blank-2" />);
|
||||
|
||||
return DashCountGroup({
|
||||
className: 'needinfos',
|
||||
key: 'needinfos',
|
||||
|
@ -187,23 +229,51 @@ function DashboardWE() {
|
|||
});
|
||||
}
|
||||
|
||||
function renderWhiteBoardTags() {
|
||||
const children = [];
|
||||
Object.keys(whiteboardTags.data).forEach((tag) => {
|
||||
children.push(
|
||||
renderChild({
|
||||
data: whiteboardTags.data,
|
||||
dataKey: tag,
|
||||
component: tag,
|
||||
title: tag.replace('stockwell', 'STW'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
children.push(<DashBlank key="ni-blank-1" />);
|
||||
|
||||
return DashCountGroup({
|
||||
className: 'whiteboardtags',
|
||||
key: 'whiteboardtags',
|
||||
children,
|
||||
title: 'Whiteboard Tags',
|
||||
description: 'Whiteboard Tags to track',
|
||||
});
|
||||
}
|
||||
|
||||
let isLoading = false;
|
||||
if (needInfos.isLoading || issueCounts.isLoading) {
|
||||
if (
|
||||
needInfos.isLoading ||
|
||||
issueCounts.isLoading ||
|
||||
whiteboardTags.isLoading
|
||||
) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Helmet>
|
||||
<body className="dash" />
|
||||
<title>Webextension Dashboard</title>
|
||||
<body className="dash" />
|
||||
</Helmet>
|
||||
<Container as="main">
|
||||
<div className="dash-container">
|
||||
{isLoading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
[renderNeedInfos(), ...renderCounts()]
|
||||
[renderNeedInfos(), ...renderCounts(), renderWhiteBoardTags()]
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
|
@ -0,0 +1,138 @@
|
|||
import { Button, Card, Col, Container, Row } from 'react-bootstrap';
|
||||
import {
|
||||
MeterIcon,
|
||||
MilestoneIcon,
|
||||
PeopleIcon,
|
||||
ProjectIcon,
|
||||
} from '@primer/octicons-react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Home Page</title>
|
||||
</Helmet>
|
||||
<br />
|
||||
<Container as="main" className="home">
|
||||
<Card>
|
||||
<Card.Header>Projects</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<ProjectIcon size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
This view shows our current projects, in-progress for the
|
||||
AMO team, plus you can navigate to previous and future
|
||||
quarters.
|
||||
</p>
|
||||
<p>
|
||||
Each project's data is provided by Github's API, and this
|
||||
view is a way to provide an overview of the projects per
|
||||
quarter for just our team.
|
||||
</p>
|
||||
<Link href="/projects/latest/" passHref>
|
||||
<Button variant="outline-primary">View Projects</Button>
|
||||
</Link>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Milestones</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<MilestoneIcon size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
Milestones are used to provide an overview of what we're
|
||||
shipping each week. You can also navigate to previous and
|
||||
next milestones.
|
||||
</p>
|
||||
<p>
|
||||
Each week we review what we're shipping with the current
|
||||
milestone and preview what's being worked on for the
|
||||
following week as part of our weekly Engineering stand-up.
|
||||
</p>
|
||||
<Link
|
||||
href="/milestones/latest/?dir=asc&sort=assignee"
|
||||
passHref
|
||||
>
|
||||
<Button variant="outline-primary">View Milestones</Button>
|
||||
</Link>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Dashboards</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<MeterIcon size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
These dashboards are used to give us an overview of what the
|
||||
issue counts look like and highlights any high priority
|
||||
bugs.
|
||||
</p>
|
||||
<Link href="/dashboards/amo/" passHref>
|
||||
<Button variant="outline-primary">
|
||||
View AMO Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboards/webext/" passHref>
|
||||
<Button variant="outline-primary">
|
||||
View Web-Extensions Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Contributions</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<PeopleIcon size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
This view shows bugs that might be suitable for a
|
||||
contributor to work on, this data is used as part of the
|
||||
bi-weekly contributor bug review.
|
||||
</p>
|
||||
<Link
|
||||
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
passHref
|
||||
>
|
||||
<Button variant="outline-primary">
|
||||
View Contributions
|
||||
</Button>
|
||||
</Link>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,306 @@
|
|||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Link from 'next/link';
|
||||
import Error from 'next/error';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import queryString from 'query-string';
|
||||
import {
|
||||
AlertIcon,
|
||||
HeartIcon,
|
||||
LinkIcon,
|
||||
PersonIcon,
|
||||
} from '@primer/octicons-react';
|
||||
import { getMilestonePagination, formatIssueData } from 'lib/utils/milestones';
|
||||
import { dateSort, sortData } from 'lib/utils/sort';
|
||||
import { getApiURL } from 'lib/utils';
|
||||
import { validMilestoneRX } from 'lib/const';
|
||||
import ActiveLink from 'components/ActiveLink';
|
||||
import HeaderLink from 'components/HeaderLink';
|
||||
|
||||
const defaultSort = 'assignee';
|
||||
const defaultSortDir = 'asc';
|
||||
const sortConfig = {
|
||||
assignee: {},
|
||||
priority: {},
|
||||
title: {},
|
||||
repo: {},
|
||||
updatedAt: {
|
||||
sortFunc: dateSort,
|
||||
},
|
||||
hasProject: {},
|
||||
state: {},
|
||||
reviewersNames: {},
|
||||
};
|
||||
|
||||
function getCurrentSortQueryString() {
|
||||
const router = useRouter();
|
||||
const { sort, dir } = router.query;
|
||||
return `?${queryString.stringify({
|
||||
dir: dir || defaultSortDir,
|
||||
sort: sort || defaultSort,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function renderAssignee(issue) {
|
||||
if (issue.assignees.nodes.length) {
|
||||
const issueAssignee = issue.assignees.nodes[0];
|
||||
return (
|
||||
<span>
|
||||
<img className="avatar" src={issueAssignee.avatarUrl} alt="" />{' '}
|
||||
{issueAssignee.login}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (issue.assignee === '01_contributor') {
|
||||
return (
|
||||
<span className="contributor">
|
||||
<HeartIcon verticalAlign="middle" /> Contributor
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="unassigned">
|
||||
<PersonIcon verticalAlign="middle" /> Unassigned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderReviewers(issue) {
|
||||
const reviewers = [];
|
||||
issue.reviewers.forEach((item) => {
|
||||
reviewers.push(
|
||||
<React.Fragment key={`${issue.number}-${item.author.login}`}>
|
||||
<a href={item.prLink} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className="avatar"
|
||||
src={item.author.avatarUrl}
|
||||
title={`Reviewed by ${item.author.login}`}
|
||||
alt=""
|
||||
/>
|
||||
</a>
|
||||
</React.Fragment>,
|
||||
);
|
||||
});
|
||||
return reviewers;
|
||||
}
|
||||
|
||||
function renderRows({ data }) {
|
||||
const rows = [];
|
||||
const colSpan = 7;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>Loading...</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>
|
||||
<p>There are no issues associated with this milestone yet.</p>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const issue = data[i] || {};
|
||||
rows.push(
|
||||
<tr key={`issue-${i}`}>
|
||||
<td className="assignee">{renderAssignee(issue)}</td>
|
||||
<td>
|
||||
<span className={issue.priority || 'unprioritized'}>
|
||||
{issue.priority ? issue.priority.toUpperCase() : <AlertIcon />}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="issueLink"
|
||||
href={issue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>#{issue.number}:</strong> {issue.title}{' '}
|
||||
<LinkIcon verticalAlign="middle" />
|
||||
</a>
|
||||
{issue.hasProject ? (
|
||||
<a
|
||||
href={issue.projectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="projectLink"
|
||||
>
|
||||
{issue.projectName}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{issue.repository.name.replace('addons-', '')}</td>
|
||||
<td>
|
||||
<TimeAgo date={issue.updatedAt} />
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="label"
|
||||
style={{
|
||||
backgroundColor: issue.stateLabelColor,
|
||||
color: issue.stateLabelTextColor,
|
||||
}}
|
||||
>
|
||||
{issue.stateLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="reviewers">{renderReviewers(issue)}</td>
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(props) {
|
||||
const { milestone } = props.params;
|
||||
const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', {
|
||||
milestone,
|
||||
});
|
||||
const res = await fetch(milestoneIssuesURL);
|
||||
const errorCode = res.ok ? false : res.status;
|
||||
const milestoneIssueData = await res.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
milestoneIssues: formatIssueData(milestoneIssueData),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Milestones = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const { sort, dir, milestone } = router.query;
|
||||
const {
|
||||
groups: { year, month, day },
|
||||
} = validMilestoneRX.exec(milestone);
|
||||
const milestonePagination = getMilestonePagination({
|
||||
startDate: new Date(year, month - 1, day),
|
||||
});
|
||||
const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', {
|
||||
milestone,
|
||||
});
|
||||
const initialMilestoneIssues = props.milestoneIssues;
|
||||
const { data: milestoneIssues } = useSWR(
|
||||
milestoneIssuesURL,
|
||||
async () => {
|
||||
const result = await fetch(milestoneIssuesURL);
|
||||
const json = await result.json();
|
||||
return formatIssueData(json);
|
||||
},
|
||||
{ initialData: initialMilestoneIssues, refreshInterval: 30000 },
|
||||
);
|
||||
|
||||
let data = milestoneIssues;
|
||||
if (sort) {
|
||||
data = sortData({ data, columnKey: sort, direction: dir, sortConfig });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Milestones">
|
||||
<Helmet>
|
||||
<title>Milestones</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<Link
|
||||
href={`/milestones/${
|
||||
milestonePagination.prevFromStart
|
||||
}/${getCurrentSortQueryString()}`}
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="prev" className="previous" active={false}>
|
||||
Previous
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link
|
||||
href={`/milestones/${
|
||||
milestonePagination.nextFromStart
|
||||
}/${getCurrentSortQueryString()}`}
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="next" className="next" active={false}>
|
||||
Next
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<ActiveLink
|
||||
href={`/milestones/${
|
||||
milestonePagination.current
|
||||
}/${getCurrentSortQueryString()}`}
|
||||
activeClassName="active"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="current" className="current">
|
||||
Current Milestone
|
||||
</Nav.Link>
|
||||
</ActiveLink>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Container as="main" bg="light">
|
||||
<h1>
|
||||
Issues for milestone:
|
||||
{milestone.replace(/-/g, '.')}
|
||||
</h1>
|
||||
<Table responsive hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="assignees">
|
||||
<HeaderLink columnKey="assignee" linkText="Assignee" />
|
||||
</th>
|
||||
<th>
|
||||
<HeaderLink columnKey="priority" linkText="Priority" />
|
||||
</th>
|
||||
<th className="issue">
|
||||
<HeaderLink columnKey="title" linkText="Issue" />
|
||||
</th>
|
||||
<th className="repo">
|
||||
<HeaderLink columnKey="repo" linkText="Repo" />
|
||||
</th>
|
||||
<th className="last-updated">
|
||||
<HeaderLink columnKey="updatedAt" linkText="Last Update" />
|
||||
</th>
|
||||
<th className="state">
|
||||
<HeaderLink columnKey="state" linkText="State" />
|
||||
</th>
|
||||
<th>
|
||||
<HeaderLink columnKey="reviewersNames" linkText="Reviewers" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{renderRows({ data })}</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Milestones;
|
|
@ -0,0 +1,24 @@
|
|||
import queryString from 'query-string';
|
||||
import { getNextMilestone, getMilestonePagination } from 'lib/utils/milestones';
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(props) {
|
||||
let queryParams = '';
|
||||
if (props.query) {
|
||||
queryParams = `?${queryString.stringify(props.query)}`;
|
||||
}
|
||||
|
||||
const milestonePagination = getMilestonePagination({
|
||||
startDate: getNextMilestone(),
|
||||
});
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/milestones/${milestonePagination.current}/${queryParams}`,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,336 @@
|
|||
import useSWR from 'swr';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Link from 'next/link';
|
||||
import Error from 'next/error';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardDeck,
|
||||
Container,
|
||||
Nav,
|
||||
Navbar,
|
||||
NavDropdown,
|
||||
Row,
|
||||
ProgressBar,
|
||||
} from 'react-bootstrap';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import { ProjectIcon, ClockIcon } from '@primer/octicons-react';
|
||||
import { getApiURL, sanitize } from 'lib/utils';
|
||||
import {
|
||||
getCurrentQuarter,
|
||||
getNextQuarter,
|
||||
getPrevQuarter,
|
||||
parseProjectMeta,
|
||||
} from 'lib/utils/projects';
|
||||
import Engineer from 'components/Engineer';
|
||||
import ActiveLink from 'components/ActiveLink';
|
||||
|
||||
export function projectSort(a, b) {
|
||||
const goalTypeA = a.meta.goalType ? a.meta.goalType : 'unclassified';
|
||||
const goalTypeB = b.meta.goalType ? b.meta.goalType : 'unclassified';
|
||||
return goalTypeA < goalTypeB ? -1 : goalTypeA > goalTypeB ? 1 : 0;
|
||||
}
|
||||
|
||||
// This function pre-computes counts and extracts metadata prior to the data being
|
||||
// added to the state. This way we're not running the same code over and over again
|
||||
// during the render.
|
||||
function buildMetaData(projectData) {
|
||||
const newProjectData = { ...projectData };
|
||||
if (projectData.data) {
|
||||
const augmentedProjects = newProjectData.data.organization.projects.nodes.map(
|
||||
(project) => {
|
||||
const [meta, updatedHTML] = parseProjectMeta(project.bodyHTML);
|
||||
project.bodyHTML = updatedHTML;
|
||||
const todoColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'To do';
|
||||
});
|
||||
const inProgressColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'In progress';
|
||||
});
|
||||
const doneColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'Done';
|
||||
});
|
||||
const todoCount =
|
||||
(todoColumn && parseInt(todoColumn.node.cards.totalCount, 10)) || 0;
|
||||
const inProgressCount =
|
||||
(inProgressColumn &&
|
||||
parseInt(inProgressColumn.node.cards.totalCount, 10)) ||
|
||||
0;
|
||||
const doneCount =
|
||||
(doneColumn && parseInt(doneColumn.node.cards.totalCount, 10)) || 0;
|
||||
const totalCount = todoCount + inProgressCount + doneCount;
|
||||
const donePerc = totalCount ? (100 / totalCount) * doneCount : 0;
|
||||
const inProgressPerc = totalCount
|
||||
? (100 / totalCount) * inProgressCount
|
||||
: 0;
|
||||
project.meta = {
|
||||
...meta,
|
||||
todoCount,
|
||||
inProgressCount,
|
||||
doneCount,
|
||||
donePerc,
|
||||
inProgressPerc,
|
||||
};
|
||||
return project;
|
||||
},
|
||||
);
|
||||
newProjectData.data.organization.projects.nodes = augmentedProjects;
|
||||
}
|
||||
return newProjectData;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(props) {
|
||||
const { year, quarter } = props.params;
|
||||
const projectURL = getApiURL('/api/gh-projects/', { quarter, year });
|
||||
const teamURL = getApiURL('/api/gh-team/');
|
||||
const [projectResponse, teamResponse] = await Promise.all([
|
||||
fetch(projectURL),
|
||||
fetch(teamURL),
|
||||
]);
|
||||
|
||||
const errorCode = projectResponse.ok ? false : projectResponse.status;
|
||||
const projects = await projectResponse.json();
|
||||
const team = await teamResponse.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
errorCode,
|
||||
projects: buildMetaData(projects),
|
||||
team,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Projects = (props) => {
|
||||
if (props.errorCode) {
|
||||
return <Error statusCode={props.errorCode} />;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const { year, quarter, projectType, engineer } = router.query;
|
||||
const projectURL = getApiURL('/api/gh-projects/', { quarter, year });
|
||||
const teamData = props.team;
|
||||
const initialProjectsData = props.projects;
|
||||
const { data: projectsData } = useSWR(
|
||||
projectURL,
|
||||
async () => {
|
||||
const result = await fetch(projectURL);
|
||||
const json = await result.json();
|
||||
return buildMetaData(json);
|
||||
},
|
||||
{ initialData: initialProjectsData, refreshInterval: 30000 },
|
||||
);
|
||||
|
||||
let projects = null;
|
||||
let currentProjectType = null;
|
||||
if (projectsData.data) {
|
||||
projectsData.data.organization.projects.nodes.sort(projectSort);
|
||||
projects = projectsData.data.organization.projects.nodes.map((project) => {
|
||||
if (projectType && projectType !== null) {
|
||||
if (!RegExp(projectType, 'i').test(project.meta.goalType)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const teamMembers = [];
|
||||
if (teamData.data) {
|
||||
for (const eng of project.meta.engineers) {
|
||||
const teamMembersToSearch = [
|
||||
...teamData.data.organization.team.members.nodes,
|
||||
...teamData.data.organization.outreachy.members.nodes,
|
||||
];
|
||||
const foundMember = teamMembersToSearch.find((item) => {
|
||||
return item.login.toLowerCase() === eng.toLowerCase();
|
||||
});
|
||||
if (foundMember) {
|
||||
teamMembers.push(foundMember);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
engineer &&
|
||||
project.meta.engineers &&
|
||||
!project.meta.engineers.includes(engineer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectNode = (
|
||||
<div className="card-wrapper" key={project.name}>
|
||||
{!projectType && currentProjectType === null ? (
|
||||
<h2 className="project-type">Primary</h2>
|
||||
) : null}
|
||||
{!projectType &&
|
||||
currentProjectType === 'primary' &&
|
||||
project.meta.goalType === 'secondary' ? (
|
||||
<h2 className="project-type">Secondary</h2>
|
||||
) : null}
|
||||
<Card bg={project.meta.goalType === 'primary' ? 'muted' : 'light'}>
|
||||
<Card.Header as="h2">
|
||||
<span>
|
||||
<ProjectIcon verticalAlign="middle" />
|
||||
{project.name}
|
||||
</span>
|
||||
<div>
|
||||
{teamMembers.map((member) => {
|
||||
return (
|
||||
<Engineer
|
||||
key={member.login + project.name}
|
||||
member={member}
|
||||
project={project}
|
||||
year={year}
|
||||
quarter={quarter}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<ProgressBar>
|
||||
<ProgressBar
|
||||
variant="success"
|
||||
now={project.meta.donePerc}
|
||||
key={1}
|
||||
/>
|
||||
<ProgressBar
|
||||
variant="warning"
|
||||
now={project.meta.inProgressPerc}
|
||||
key={2}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<Card.Body
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(project.bodyHTML),
|
||||
}}
|
||||
/>
|
||||
<Card.Footer bg="light">
|
||||
<span className="updated float-left">
|
||||
<ClockIcon /> Updated
|
||||
<TimeAgo date={project.updatedAt} />
|
||||
</span>
|
||||
<Button
|
||||
href={project.url}
|
||||
size="sm"
|
||||
className="float-right"
|
||||
variant="outline-primary"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
View Project
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
|
||||
currentProjectType = project.meta.goalType;
|
||||
return projectNode;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter null elements. This avoids a case where we have
|
||||
// a projects list that looks like [null, null].
|
||||
if (projects && projects.length) {
|
||||
projects = projects.filter((el) => {
|
||||
return el !== null;
|
||||
});
|
||||
}
|
||||
|
||||
const { year: currentYear, quarter: currentQuarter } = getCurrentQuarter();
|
||||
const { year: prevYear, quarter: prevQuarter } = getPrevQuarter({
|
||||
year,
|
||||
quarter,
|
||||
});
|
||||
const { year: nextYear, quarter: nextQuarter } = getNextQuarter({
|
||||
year,
|
||||
quarter,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Projects</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<Link href={`/projects/${prevYear}/${prevQuarter}/`} passHref>
|
||||
<Nav.Link eventKey="prev" className="previous" active={false}>
|
||||
Previous
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={`/projects/${nextYear}/${nextQuarter}/`} passHref>
|
||||
<Nav.Link eventKey="next" className="next" active={false}>
|
||||
Next
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<ActiveLink
|
||||
href={`/projects/${currentYear}/${currentQuarter}/`}
|
||||
activeClassName="active"
|
||||
passHref
|
||||
>
|
||||
<Nav.Link eventKey="current">Current Quarter</Nav.Link>
|
||||
</ActiveLink>
|
||||
</Nav.Item>
|
||||
|
||||
<NavDropdown className="filters" title="Filters" alignRight>
|
||||
<Link href={`/projects/${year}/${quarter}/`} passHref>
|
||||
<NavDropdown.Item eventKey="all">All</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${year}/${quarter}/?projectType=primary`}
|
||||
passHref
|
||||
>
|
||||
<NavDropdown.Item eventKey="primary">Primary</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${year}/${quarter}/?projectType=secondary`}
|
||||
passHref
|
||||
>
|
||||
<NavDropdown.Item eventKey="secondary">
|
||||
Secondary
|
||||
</NavDropdown.Item>
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
|
||||
<Container fluid>
|
||||
<Row>
|
||||
<Container as="main" bg="light">
|
||||
<h1>
|
||||
Projects for {quarter} {year}{' '}
|
||||
{projectType
|
||||
? `(${projectType})`
|
||||
: engineer
|
||||
? `(${engineer})`
|
||||
: '(All)'}
|
||||
</h1>
|
||||
{projectsData.data === null ? <p>Loading...</p> : null}
|
||||
{projects && projects.length ? (
|
||||
<CardDeck>{projects}</CardDeck>
|
||||
) : projects && projects.length === 0 ? (
|
||||
<p>There are no Projects available for this quarter yet</p>
|
||||
) : null}
|
||||
</Container>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
|
@ -0,0 +1,17 @@
|
|||
import { getCurrentQuarter } from 'lib/utils/projects';
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps() {
|
||||
const { year, quarter } = getCurrentQuarter();
|
||||
const destination = `/projects/${year}/${quarter}/`;
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Add-ons PM</title>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
До Ширина: | Высота: | Размер: 32 KiB После Ширина: | Высота: | Размер: 32 KiB |
|
@ -1,154 +0,0 @@
|
|||
import React from 'react';
|
||||
import './App.scss';
|
||||
|
||||
import { Nav, Navbar } from 'react-bootstrap';
|
||||
import { IndexLinkContainer, LinkContainer } from 'react-router-bootstrap';
|
||||
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Route,
|
||||
Redirect,
|
||||
Switch,
|
||||
} from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Octicon, { MarkGithub } from '@githubprimer/octicons-react';
|
||||
|
||||
import Home from './Home';
|
||||
import DashboardAMO from './DashboardAMO';
|
||||
import DashboardWE from './DashboardWE';
|
||||
import Projects from './Projects';
|
||||
import GoodFirstBugs from './ContribGoodFirstBugs';
|
||||
import MaybeGoodFirstBugs from './ContribMaybeGoodFirstBugs';
|
||||
import ContribWelcome from './ContribWelcome';
|
||||
import Milestones from './Milestones';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
import { validProjectTeamMembers, validYears } from '../const';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Router>
|
||||
<div>
|
||||
<Helmet defaultTitle="Add-ons PM" titleTemplate="%s - Add-ons PM" />
|
||||
<Navbar bg="dark" variant="dark">
|
||||
<IndexLinkContainer to="/">
|
||||
<Navbar.Brand>Addons PM</Navbar.Brand>
|
||||
</IndexLinkContainer>
|
||||
<Nav className="mr-auto">
|
||||
<Nav.Item>
|
||||
<LinkContainer to="/projects/latest/">
|
||||
<Nav.Link eventKey={1}>Projects</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to="/milestones/latest/?dir=asc&sort=assignee"
|
||||
isActive={(match, location) => {
|
||||
return location.pathname.indexOf('/milestones') > -1;
|
||||
}}
|
||||
>
|
||||
<Nav.Link eventKey={2}>Milestones</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer to="/dashboards/amo/">
|
||||
<Nav.Link eventKey={3}>AMO Dashboard</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer to="/dashboards/webext/">
|
||||
<Nav.Link eventKey={4}>Webext Dashboard</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
isActive={(match, location) => {
|
||||
return location.pathname.indexOf('/contrib') > -1;
|
||||
}}
|
||||
>
|
||||
<Nav.Link eventKey={5}>Contributions</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
<Nav className="mr-sm-2">
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
data-ref="src"
|
||||
eventKey="src"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/mozilla/addons-pm/"
|
||||
>
|
||||
<Octicon
|
||||
icon={MarkGithub}
|
||||
verticalAlign="middle"
|
||||
ariaLabel="View on Github"
|
||||
size="medium"
|
||||
/>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route
|
||||
exact
|
||||
path={`/milestones/:year(${validYears.join(
|
||||
'|',
|
||||
)}).:month(0[1-9]|1[0-2]).:day(0[1-9]|[1-2]\\d|3[0-1])/`}
|
||||
component={Milestones}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/milestones/:milestone(latest)/"
|
||||
component={Milestones}
|
||||
/>
|
||||
<Redirect from="/dashboard/" to="/dashboards/amo/" />
|
||||
<Route exact path="/dashboards/amo/" component={DashboardAMO} />
|
||||
<Route exact path="/dashboards/webext/" component={DashboardWE} />
|
||||
<Route
|
||||
exact
|
||||
path="/contrib/maybe-good-first-bugs/"
|
||||
component={MaybeGoodFirstBugs}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/contrib/good-first-bugs/"
|
||||
component={GoodFirstBugs}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/contrib/contrib-welcome/"
|
||||
component={ContribWelcome}
|
||||
/>
|
||||
<Route exact path="/projects/:year(latest)/" component={Projects} />
|
||||
<Route
|
||||
exact
|
||||
path={`/projects/:year(${validYears.join('|')})/:quarter(Q[1-4])/`}
|
||||
component={Projects}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/projects/:year(${validYears.join(
|
||||
'|',
|
||||
)})/:quarter(Q[1-4])/:projectType(primary|secondary)/`}
|
||||
component={Projects}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/projects/:year(${validYears.join(
|
||||
'|',
|
||||
)})/:quarter(Q[1-4])/:engineer(${validProjectTeamMembers.join(
|
||||
'|',
|
||||
)})/`}
|
||||
component={Projects}
|
||||
/>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
|
@ -1,272 +0,0 @@
|
|||
body {
|
||||
background: #f8f9fa !important;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
h1,
|
||||
.h1 {
|
||||
color: #666;
|
||||
font-size: 2rem !important;
|
||||
margin-top: 0.5em !important;
|
||||
position: sticky;
|
||||
text-align: center;
|
||||
top: 10px;
|
||||
z-index: 1040;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
main.container {
|
||||
max-width: 1360px;
|
||||
}
|
||||
}
|
||||
|
||||
nav[aria-label='breadcrumb'] {
|
||||
margin-bottom: 10px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.navbar.d-flex {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
nav[aria-label='breadcrumb'] {
|
||||
margin-bottom: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.navbar.d-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
height: 150px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-wrapper,
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2.card-header {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-header svg {
|
||||
margin-bottom: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.navbar h1,
|
||||
.navbar h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card .progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card .progress,
|
||||
.card .progressbar {
|
||||
border-radius: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.updated {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.eng-image {
|
||||
border: 2px solid #ccc;
|
||||
box-sizing: content-box;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
bottom: 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
|
||||
left: 0;
|
||||
padding: 25px 0 0 0; /* Height of navbar */
|
||||
position: sticky;
|
||||
top: 25px;
|
||||
z-index: -1; /* Behind the navbar */
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
height: calc(100vh - 50px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||
padding-top: 0.5rem;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar .nav-link .feather {
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover .feather,
|
||||
.sidebar .nav-link.active .feather {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav .breadcrumb {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.navbar-brand svg {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-type {
|
||||
color: #666;
|
||||
font-size: 22px;
|
||||
margin: 20px 0 15px 15px;
|
||||
}
|
||||
|
||||
div.table-responsive {
|
||||
overflow-x: unset;
|
||||
}
|
||||
|
||||
table.table {
|
||||
position: relative;
|
||||
|
||||
thead th {
|
||||
background: #f8f9fa;
|
||||
position: sticky;
|
||||
top: 55px;
|
||||
|
||||
&::after {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
bottom: -2px;
|
||||
content: '';
|
||||
left: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th a {
|
||||
&:link,
|
||||
&:visited,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.sort-direction {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&::after {
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
margin: 0 0 2px 5px;
|
||||
}
|
||||
|
||||
&.asc::after {
|
||||
border-bottom: 5px solid black;
|
||||
}
|
||||
|
||||
&.desc::after {
|
||||
border-top: 5px solid black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p1,
|
||||
.p2,
|
||||
.p3,
|
||||
.p4,
|
||||
.p5,
|
||||
.unprioritized {
|
||||
background: #999;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 5px 5px 3px;
|
||||
}
|
||||
|
||||
.p1 {
|
||||
background: #ff0039;
|
||||
}
|
||||
|
||||
.p2 {
|
||||
background: #d70022;
|
||||
}
|
||||
|
||||
.p3 {
|
||||
background: #a4000f;
|
||||
}
|
||||
|
||||
.p4 {
|
||||
background: #5a0002;
|
||||
}
|
||||
|
||||
.p5 {
|
||||
background: #3e0200;
|
||||
}
|
||||
|
||||
.no,
|
||||
.yes {
|
||||
background: green;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 5px 5px 3px;
|
||||
}
|
||||
|
||||
.no {
|
||||
background: orange;
|
||||
}
|
||||
|
||||
table {
|
||||
border: 0;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 30px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.centered {
|
||||
text-align: center;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<App />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
|
@ -1,142 +0,0 @@
|
|||
import {
|
||||
API_ROOT,
|
||||
validQuarterRX,
|
||||
validYearRX,
|
||||
validMilestoneRX,
|
||||
} from '../const';
|
||||
|
||||
async function getProjects(year, quarter) {
|
||||
if (!validYearRX.test(year)) {
|
||||
throw new Error('Invalid Year');
|
||||
}
|
||||
|
||||
if (!validQuarterRX.test(quarter)) {
|
||||
throw new Error('Invalid Quarter');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_ROOT}/projects/?year=${year}&quarter=${quarter}`,
|
||||
{
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
);
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getMilestoneIssues(milestone) {
|
||||
if (!validMilestoneRX.test(milestone)) {
|
||||
throw new Error('Invalid Milestone');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_ROOT}/milestone-issues/?milestone=${milestone}`,
|
||||
{
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
);
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getTeam() {
|
||||
const response = await fetch(`${API_ROOT}/team/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getGithubIssueCounts() {
|
||||
const response = await fetch(`${API_ROOT}/github-issue-counts/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getBugzillaIssueCounts() {
|
||||
const response = await fetch(`${API_ROOT}/bugzilla-issue-counts/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getBugzillaNeedInfos() {
|
||||
const response = await fetch(`${API_ROOT}/bugzilla-need-infos/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getGoodFirstBugs() {
|
||||
const response = await fetch(`${API_ROOT}/good-first-bugs/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getContribWelcome() {
|
||||
const response = await fetch(`${API_ROOT}/contrib-welcome/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
async function getMaybeGoodFirstBugs() {
|
||||
const response = await fetch(`${API_ROOT}/maybe-good-first-bugs/`, {
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
});
|
||||
checkStatus(response);
|
||||
return parseJSON(response);
|
||||
}
|
||||
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
}
|
||||
const error = new Error(`HTTP Error ${response.statusText}`);
|
||||
error.status = response.statusText;
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const Client = {
|
||||
checkStatus,
|
||||
getProjects,
|
||||
getTeam,
|
||||
getGithubIssueCounts,
|
||||
getBugzillaIssueCounts,
|
||||
getBugzillaNeedInfos,
|
||||
getContribWelcome,
|
||||
getGoodFirstBugs,
|
||||
getMaybeGoodFirstBugs,
|
||||
getMilestoneIssues,
|
||||
};
|
||||
export default Client;
|
|
@ -1,43 +0,0 @@
|
|||
import Client from './Client';
|
||||
|
||||
describe('Client.getProjects()', () => {
|
||||
it('throws if input year is bad', async () => {
|
||||
try {
|
||||
await Client.getProjects('bad-year', 'Q3');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch('Invalid Year');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws if input quarter is bad', async () => {
|
||||
try {
|
||||
await Client.getProjects('2018', 'Q20');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch('Invalid Quarter');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client.checkStatus()', () => {
|
||||
it('throws if response status is not 200', () => {
|
||||
const response = new Response('FAIL', {
|
||||
status: 500,
|
||||
statusText: 'soz',
|
||||
});
|
||||
try {
|
||||
Client.checkStatus(response);
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/soz/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client.getMilestones()', () => {
|
||||
it('throws if milestone is invalie', async () => {
|
||||
try {
|
||||
await Client.getMilestoneIssues('whatever');
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch('Invalid Milestone');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,309 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import queryString from 'query-string';
|
||||
|
||||
import Octicon, { Alert, Link } from '@githubprimer/octicons-react';
|
||||
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import {
|
||||
alphaSort,
|
||||
dateSort,
|
||||
numericSort,
|
||||
hasLabelContainingString,
|
||||
} from './utils';
|
||||
|
||||
import { priorities } from '../const';
|
||||
|
||||
import './Contrib.scss';
|
||||
|
||||
// These views display assigment columns.
|
||||
const dataKeysWithAssignment = ['goodFirstBugs', 'contribWelcome'];
|
||||
|
||||
class BaseContrib extends Component {
|
||||
formatData(data) {
|
||||
let issues = [];
|
||||
data.forEach((item) => {
|
||||
const issue = {
|
||||
...item.issue,
|
||||
priority: '',
|
||||
assigned: false,
|
||||
mentorAssigned: false,
|
||||
};
|
||||
const labels = issue.labels.nodes || [];
|
||||
priorities.forEach((priority) => {
|
||||
if (hasLabelContainingString(labels, priority)) {
|
||||
issue.priority = priority;
|
||||
}
|
||||
});
|
||||
if (hasLabelContainingString(labels, 'contrib: assigned')) {
|
||||
issue.assigned = true;
|
||||
}
|
||||
if (hasLabelContainingString(labels, 'contrib: mentor assigned')) {
|
||||
issue.mentorAssigned = true;
|
||||
}
|
||||
if (issue.repository && issue.repository.name) {
|
||||
issue.repo = issue.repository.name;
|
||||
}
|
||||
issues.push(issue);
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
setArrow(column) {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
let className = 'sort-direction';
|
||||
if (qs.sort === column) {
|
||||
className = `${className} ${qs.dir === 'asc' ? ' asc' : ' desc'}`;
|
||||
}
|
||||
return className;
|
||||
}
|
||||
|
||||
sortConfig = {
|
||||
priority: {},
|
||||
title: {},
|
||||
repo: {},
|
||||
assigned: {
|
||||
sortFunc: numericSort,
|
||||
},
|
||||
mentorAssigned: {
|
||||
sortFunc: numericSort,
|
||||
},
|
||||
updatedAt: {
|
||||
sortFunc: dateSort,
|
||||
},
|
||||
};
|
||||
|
||||
sortData({ dataKey, columnKey, direction } = {}) {
|
||||
const data = this.state[dataKey];
|
||||
if (!data) {
|
||||
console.debug('No data yet, bailing');
|
||||
return data;
|
||||
}
|
||||
if (!Object.keys(this.sortConfig).includes(columnKey)) {
|
||||
console.debug(
|
||||
`"${columnKey}" does not match one of "${Object.keys(
|
||||
this.sortConfig,
|
||||
).join(', ')}"`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
if (!['desc', 'asc'].includes(direction)) {
|
||||
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
|
||||
return data;
|
||||
}
|
||||
|
||||
const sortFunc = this.sortConfig[columnKey].sortFunc || alphaSort;
|
||||
const sorted = [].concat(data).sort(sortFunc(columnKey));
|
||||
|
||||
// Reverse for desc.
|
||||
if (direction === 'desc') {
|
||||
sorted.reverse();
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
renderMentorAssigned(issue) {
|
||||
let mentorAssigned = <span className="mentor not-assigned">NO</span>;
|
||||
if (issue.mentorAssigned) {
|
||||
mentorAssigned = <span className="mentor">YES</span>;
|
||||
}
|
||||
return mentorAssigned;
|
||||
}
|
||||
|
||||
renderContribAssigned(issue) {
|
||||
let assigned = <span className="contributor not-assigned">NO</span>;
|
||||
if (issue.assigned) {
|
||||
assigned = <span className="contributor">YES</span>;
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
renderHeaderLink(column, name) {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
const sort = qs.sort;
|
||||
let linkDir = 'desc';
|
||||
let classDir = qs.dir === 'asc' ? 'asc' : 'desc';
|
||||
let className = 'sort-direction';
|
||||
if (sort === column) {
|
||||
linkDir = qs.dir === 'desc' ? 'asc' : 'desc';
|
||||
className = `${className} ${classDir}`;
|
||||
}
|
||||
const query = `?${queryString.stringify({
|
||||
...qs,
|
||||
dir: linkDir,
|
||||
sort: column,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<LinkContainer to={query}>
|
||||
<a className={className} href={query}>
|
||||
{name}
|
||||
</a>
|
||||
</LinkContainer>
|
||||
);
|
||||
}
|
||||
|
||||
renderRows(data) {
|
||||
const rows = [];
|
||||
const colSpan = 4;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>Loading...</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>
|
||||
<div className="not-found">
|
||||
<p>
|
||||
No issues found! Time to deploy the team to find some quality
|
||||
bugs!
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
const issue = data[i] || {};
|
||||
rows.push(
|
||||
<tr key={`issue-${i}`}>
|
||||
<td>
|
||||
<span className={issue.priority || 'unprioritized'}>
|
||||
{issue.priority ? (
|
||||
issue.priority.toUpperCase()
|
||||
) : (
|
||||
<Octicon icon={Alert} />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href={issue.url} target="_blank" rel="noopener noreferrer">
|
||||
<strong>#{issue.number}:</strong> {issue.title}{' '}
|
||||
<Octicon icon={Link} verticalAlign="middle" />
|
||||
</a>
|
||||
</td>
|
||||
<td>{issue.repository.name.replace('addons-', '')}</td>
|
||||
{dataKeysWithAssignment.includes(this.state.dataKey) ? (
|
||||
<td className="centered">{this.renderContribAssigned(issue)}</td>
|
||||
) : null}
|
||||
{dataKeysWithAssignment.includes(this.state.dataKey) ? (
|
||||
<td className="centered">{this.renderMentorAssigned(issue)}</td>
|
||||
) : null}
|
||||
<td>
|
||||
<TimeAgo date={issue.updatedAt} />
|
||||
</td>
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
|
||||
let data;
|
||||
const dataKey = this.state.dataKey;
|
||||
|
||||
data = this.state[dataKey];
|
||||
if (qs.sort) {
|
||||
data = this.sortData({ dataKey, columnKey: qs.sort, direction: qs.dir });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Contrib">
|
||||
<Helmet>
|
||||
<title>Contributions</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
isActive={(match, location) => {
|
||||
return (
|
||||
location.pathname.indexOf(
|
||||
'/contrib/maybe-good-first-bugs',
|
||||
) > -1
|
||||
);
|
||||
}}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="mgfb">Maybe Good First Bugs</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to="/contrib/good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
isActive={(match, location) => {
|
||||
return (
|
||||
location.pathname.indexOf('/contrib/good-first-bugs') > -1
|
||||
);
|
||||
}}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="gfb">Good First Bugs</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to="/contrib/contrib-welcome/?dir=desc&sort=updatedAt"
|
||||
isActive={(match, location) => {
|
||||
return (
|
||||
location.pathname.indexOf('/contrib/contrib-welcome') > -1
|
||||
);
|
||||
}}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="cw">Contrib Welcome</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Container as="main" bg="light">
|
||||
<Table responsive hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{this.renderHeaderLink('priority', 'Priority')}</th>
|
||||
<th>{this.renderHeaderLink('title', 'Issue')}</th>
|
||||
<th className="repo">
|
||||
{this.renderHeaderLink('repo', 'Repo')}
|
||||
</th>
|
||||
{dataKeysWithAssignment.includes(dataKey) ? (
|
||||
<th>{this.renderHeaderLink('assigned', 'Assigned?')}</th>
|
||||
) : null}
|
||||
{dataKeysWithAssignment.includes(dataKey) ? (
|
||||
<th>
|
||||
{this.renderHeaderLink('mentorAssigned', 'Has Mentor?')}
|
||||
</th>
|
||||
) : null}
|
||||
<th className="last-updated">
|
||||
{this.renderHeaderLink('updatedAt', 'Last Update')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{this.renderRows(data)}</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseContrib;
|
|
@ -1,29 +0,0 @@
|
|||
.Contrib {
|
||||
.contributor,
|
||||
.mentor {
|
||||
background: green;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 5px 5px 3px;
|
||||
|
||||
&.not-assigned {
|
||||
background: orange;
|
||||
}
|
||||
}
|
||||
|
||||
.not-found {
|
||||
background: url('images/blue-berror.svg') no-repeat 50% 50%;
|
||||
min-height: 450px;
|
||||
padding-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
min-width: 9em;
|
||||
}
|
||||
|
||||
th.repo {
|
||||
width: 11em;
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import GoodFirstBugs from './ContribGoodFirstBugs';
|
||||
import MaybeGoodFirstBugs from './ContribMaybeGoodFirstBugs';
|
||||
import ContribWelcome from './ContribWelcome';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
describe('Contributions', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mock(/\/api\/good-first-bugs\//, testData.goodFirstBugs);
|
||||
fetchMock.mock(
|
||||
/\/api\/maybe-good-first-bugs\//,
|
||||
testData.maybeGoodFirstBugs,
|
||||
);
|
||||
fetchMock.mock(/\/api\/contrib-welcome\//, testData.contribWelcome);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should render some good first bugs', async () => {
|
||||
const fakeLocation = {
|
||||
pathname: '/contributions/good-first-bugs/',
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<GoodFirstBugs location={fakeLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('GoodFirstBugs').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const issueData = instance.state.goodFirstBugs;
|
||||
const expectedNumberOfRows = Object.keys(issueData).length;
|
||||
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
|
||||
});
|
||||
|
||||
it('should render some maybe good first bugs', async () => {
|
||||
const fakeLocation = {
|
||||
pathname: '/contributions/maybe-good-first-bugs/',
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<MaybeGoodFirstBugs location={fakeLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('MaybeGoodFirstBugs').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const issueData = instance.state.maybeGoodFirstBugs;
|
||||
const expectedNumberOfRows = Object.keys(issueData).length;
|
||||
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
|
||||
});
|
||||
|
||||
it('should render some contrib welcome bugs', async () => {
|
||||
const fakeLocation = {
|
||||
pathname: '/contributions/contrib-welcome/',
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<ContribWelcome location={fakeLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('ContribWelcome').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const issueData = instance.state.contribWelcome;
|
||||
const expectedNumberOfRows = Object.keys(issueData).length;
|
||||
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
|
||||
});
|
||||
});
|
|
@ -1,20 +0,0 @@
|
|||
import BaseContrib from './Contrib';
|
||||
import Client from './Client';
|
||||
|
||||
class GoodFirstBugs extends BaseContrib {
|
||||
state = {
|
||||
goodFirstBugs: null,
|
||||
dataKey: 'goodFirstBugs',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const goodFirstBugs = await Client.getGoodFirstBugs();
|
||||
this.setState({
|
||||
goodFirstBugs: this.formatData(
|
||||
goodFirstBugs.data.good_first_bugs.results,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default GoodFirstBugs;
|
|
@ -1,20 +0,0 @@
|
|||
import BaseContrib from './Contrib';
|
||||
import Client from './Client';
|
||||
|
||||
class MaybeGoodFirstBugs extends BaseContrib {
|
||||
state = {
|
||||
maybeGoodFirstBugs: null,
|
||||
dataKey: 'maybeGoodFirstBugs',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const maybeGoodFirstBugs = await Client.getMaybeGoodFirstBugs();
|
||||
this.setState({
|
||||
maybeGoodFirstBugs: this.formatData(
|
||||
maybeGoodFirstBugs.data.maybe_good_first_bugs.results,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default MaybeGoodFirstBugs;
|
|
@ -1,20 +0,0 @@
|
|||
import BaseContrib from './Contrib';
|
||||
import Client from './Client';
|
||||
|
||||
class ContribWelcome extends BaseContrib {
|
||||
state = {
|
||||
contribWelcome: null,
|
||||
dataKey: 'contribWelcome',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const contribWelcome = await Client.getContribWelcome();
|
||||
this.setState({
|
||||
contribWelcome: this.formatData(
|
||||
contribWelcome.data.contrib_welcome.results,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ContribWelcome;
|
|
@ -1,65 +0,0 @@
|
|||
.dash {
|
||||
background: #1e2430 !important;
|
||||
|
||||
.loading {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard .container {
|
||||
max-width: 100%;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.card-grp {
|
||||
display: flex;
|
||||
margin: 0.6rem 0;
|
||||
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
max-height: 100%;
|
||||
|
||||
&:focus {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-top-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
border-top-right-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
color: #ccc;
|
||||
font-weight: 200;
|
||||
text-shadow: -1px -1px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.repo-card .card-header {
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card svg text {
|
||||
font-size: 2vw;
|
||||
}
|
||||
|
||||
.card-grp.needinfos .card {
|
||||
background: #38383d !important;
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from 'react-bootstrap';
|
||||
|
||||
import Client from './Client';
|
||||
import AMODashCountGroup from './components/AMODashCountGroup';
|
||||
|
||||
import './Dashboard.scss';
|
||||
|
||||
class DashboardAMO extends Component {
|
||||
state = {
|
||||
issueCounts: {
|
||||
data: null,
|
||||
},
|
||||
};
|
||||
|
||||
async getIssueCounts() {
|
||||
return await Client.getGithubIssueCounts();
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const issueCounts = await this.getIssueCounts();
|
||||
this.setState({
|
||||
issueCounts: issueCounts,
|
||||
});
|
||||
}
|
||||
|
||||
renderCounts() {
|
||||
const issueCountData = this.state.issueCounts.data;
|
||||
const countGroups = [];
|
||||
Object.keys(issueCountData).forEach((repo, index) => {
|
||||
countGroups.push(
|
||||
<AMODashCountGroup
|
||||
key={repo + index}
|
||||
issueCounts={issueCountData[repo]}
|
||||
description={issueCountData[repo].description}
|
||||
repo={repo}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return countGroups;
|
||||
}
|
||||
|
||||
render() {
|
||||
const issueCounts = this.state.issueCounts;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Helmet>
|
||||
<body className="dash" />
|
||||
<title>AMO Dashboard</title>
|
||||
</Helmet>
|
||||
<Container as="main">
|
||||
<div className="dash-container">
|
||||
{issueCounts.data === null ? (
|
||||
<div className="loading">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
this.renderCounts()
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DashboardAMO;
|
|
@ -1,37 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import DashboardAMO from './DashboardAMO';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const fakeLocation = {
|
||||
pathname: '/dashboards/amo/',
|
||||
};
|
||||
|
||||
describe('Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mock(/\/api\/github-issue-counts\//, testData.githubIssueCounts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should render some projects', async () => {
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<DashboardAMO location={fakeLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('DashboardAMO').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const issueData = instance.state.issueCounts;
|
||||
const expectedNumberOfDashGroups = Object.keys(issueData.data).length;
|
||||
expect(wrapper.find('.card-grp')).toHaveLength(expectedNumberOfDashGroups);
|
||||
});
|
||||
});
|
|
@ -1,41 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import DashboardWE from './DashboardWE';
|
||||
|
||||
const fakeLocation = {
|
||||
pathname: '/dashboards/webext/',
|
||||
};
|
||||
|
||||
describe('Webext Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mock(
|
||||
/\/api\/bugzilla-issue-counts\//,
|
||||
testData.bugzillaIssueCountsLocal,
|
||||
);
|
||||
fetchMock.mock(
|
||||
/\/api\/bugzilla-need-infos\//,
|
||||
testData.bugzillaNeedsInfoLocal,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should render the webextension dashboard groups', async () => {
|
||||
const { findAllByText } = render(<DashboardWE location={fakeLocation} />);
|
||||
|
||||
// All the dashgroups.
|
||||
const cardGroups = await findAllByText(/.*?/, { selector: '.card-grp' });
|
||||
expect(cardGroups).toHaveLength(4);
|
||||
// The needinfo group.
|
||||
const needInfos = await findAllByText(/.*?/, {
|
||||
selector: '.card-grp.needinfos',
|
||||
});
|
||||
expect(needInfos).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -1,156 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Button, Card, Col, Container, Row } from 'react-bootstrap';
|
||||
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import Octicon, {
|
||||
Dashboard,
|
||||
Milestone,
|
||||
Organization,
|
||||
Project,
|
||||
} from '@githubprimer/octicons-react';
|
||||
|
||||
class Home extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Home Page</title>
|
||||
</Helmet>
|
||||
<br />
|
||||
<Container as="main" className="home">
|
||||
<Card>
|
||||
<Card.Header>Projects</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<Octicon icon={Project} size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
This view shows our current projects, in-progress for the
|
||||
AMO team, plus you can navigate to previous and future
|
||||
quarters.
|
||||
</p>
|
||||
<p>
|
||||
Each project's data is provided by Github's API, and this
|
||||
view is a way to provide an overview of the projects per
|
||||
quarter for just our team.
|
||||
</p>
|
||||
<LinkContainer to="/projects/latest/" exact>
|
||||
<Button
|
||||
href="/projects/latest/"
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Projects
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Milestones</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<Octicon icon={Milestone} size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
Milestones are used to provide an overview of what we're
|
||||
shipping each week. You can also navigate to previous and
|
||||
next milestones.
|
||||
</p>
|
||||
<p>
|
||||
Each week we review what we're shipping with the current
|
||||
milestone and preview what's being worked on for the
|
||||
following week as part of our weekly Engineering stand-up.
|
||||
</p>
|
||||
<LinkContainer to="/milestones/latest/" exact>
|
||||
<Button
|
||||
href="/milestones/latest/"
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Milestones
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Dashboard</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<Octicon icon={Dashboard} size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
This tool is used to give us an overview of what the issue
|
||||
counts look like and highlights any high priority bugs
|
||||
</p>
|
||||
<LinkContainer to="/dashboards/amo/" exact>
|
||||
<Button href="/dashboards/amo/" variant="outline-primary">
|
||||
View AMO Dashboard
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
<LinkContainer to="/dashboards/webext/" exact>
|
||||
<Button
|
||||
href="/dashboards/webext/"
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Web-Extensions Dashboard
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header>Contributions</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col md="auto">
|
||||
<Octicon icon={Organization} size="large" />
|
||||
</Col>
|
||||
<Col>
|
||||
<Card.Text as="div">
|
||||
<p>
|
||||
This view shows bugs that might be suitable for a
|
||||
contributor to work on, this data is used as part of the
|
||||
bi-weekly contributor bug review.
|
||||
</p>
|
||||
<LinkContainer
|
||||
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
|
||||
exact
|
||||
>
|
||||
<Button
|
||||
href="/contrib/maybe-good-first-bugs/?dir=d3esc&sort-updatedAt"
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Contributions
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Home;
|
|
@ -1,524 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import queryString from 'query-string';
|
||||
|
||||
import Octicon, {
|
||||
Alert,
|
||||
Link,
|
||||
Heart,
|
||||
Person,
|
||||
} from '@githubprimer/octicons-react';
|
||||
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import { getMilestonePagination, getNextMilestone } from './utils';
|
||||
import Client from './Client';
|
||||
import {
|
||||
alphaSort,
|
||||
colourIsLight,
|
||||
dateSort,
|
||||
hasLabelContainingString,
|
||||
hasLabel,
|
||||
} from './utils';
|
||||
|
||||
import { colors, priorities } from '../const';
|
||||
|
||||
import './Milestones.scss';
|
||||
|
||||
const defaultSort = 'assignee';
|
||||
const defaultSortDir = 'asc';
|
||||
|
||||
class Milestones extends Component {
|
||||
state = {
|
||||
milestoneIssues: null,
|
||||
pagination: {},
|
||||
};
|
||||
|
||||
formatData(data) {
|
||||
let issues = [];
|
||||
|
||||
data.forEach((item) => {
|
||||
const issue = {
|
||||
...item.issue,
|
||||
priority: null,
|
||||
hasProject: false,
|
||||
isContrib: false,
|
||||
assignee: '00_unassigned',
|
||||
};
|
||||
const labels = issue.labels.nodes || [];
|
||||
priorities.forEach((priority) => {
|
||||
if (hasLabelContainingString(labels, priority)) {
|
||||
issue.priority = priority;
|
||||
}
|
||||
});
|
||||
if (hasLabelContainingString(labels, 'contrib: assigned')) {
|
||||
issue.isContrib = true;
|
||||
issue.assignee = '01_contributor';
|
||||
}
|
||||
if (issue.repository && issue.repository.name) {
|
||||
issue.repo = issue.repository.name;
|
||||
}
|
||||
if (issue.projectCards.nodes.length) {
|
||||
issue.hasProject = true;
|
||||
issue.projectUrl = issue.projectCards.nodes[0].project.url;
|
||||
issue.projectName = issue.projectCards.nodes[0].project.name;
|
||||
}
|
||||
|
||||
issue.stateLabel = issue.state.toLowerCase();
|
||||
issue.stateLabelColor =
|
||||
issue.state === 'CLOSED' ? colors.closed : colors.open;
|
||||
|
||||
if (
|
||||
issue.state === 'OPEN' &&
|
||||
hasLabel(labels, 'state: pull request ready')
|
||||
) {
|
||||
issue.stateLabel = 'PR ready';
|
||||
issue.stateLabelColor = colors.prReady;
|
||||
} else if (
|
||||
issue.state === 'OPEN' &&
|
||||
hasLabel(labels, 'state: in progress')
|
||||
) {
|
||||
issue.stateLabel = 'in progress';
|
||||
issue.stateLabelColor = colors.inProgress;
|
||||
} else if (
|
||||
issue.state === 'CLOSED' &&
|
||||
hasLabel(labels, 'state: verified fixed')
|
||||
) {
|
||||
issue.stateLabel = 'verified fixed';
|
||||
issue.stateLabelColor = colors.verified;
|
||||
} else if (
|
||||
issue.state === 'CLOSED' &&
|
||||
hasLabel(labels, 'qa: not needed')
|
||||
) {
|
||||
issue.stateLabel = 'closed QA-';
|
||||
issue.stateLabelColor = colors.verified;
|
||||
}
|
||||
|
||||
issue.stateLabelTextColor = colourIsLight(issue.stateLabelColor)
|
||||
? '#000'
|
||||
: '#fff';
|
||||
|
||||
if (issue.assignees.nodes.length) {
|
||||
issue.assignee = issue.assignees.nodes[0].login;
|
||||
}
|
||||
|
||||
issue.reviewers = [];
|
||||
const reviewersListSeen = [];
|
||||
|
||||
if (issue.state === 'CLOSED') {
|
||||
issue.timelineItems.edges.forEach((item) => {
|
||||
if (!item.event.source.reviews) {
|
||||
// This is not a pull request item.
|
||||
return;
|
||||
}
|
||||
const bodyText = item.event.source.bodyText;
|
||||
const issueTestRx = new RegExp(`Fix(?:es)? #${issue.number}`, 'i');
|
||||
|
||||
// Only add the review if the PR contains a `Fixes #num` or `Fix #num` line that
|
||||
// matches the original issue.
|
||||
if (issueTestRx.test(bodyText)) {
|
||||
item.event.source.reviews.edges.forEach(
|
||||
({ review: { author } }) => {
|
||||
if (!reviewersListSeen.includes(author.login)) {
|
||||
reviewersListSeen.push(author.login);
|
||||
issue.reviewers.push({
|
||||
author,
|
||||
prLink: item.event.source.permalink,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quick and dirty way to provide a sortable key for reviewers.
|
||||
issue.reviewersNames = '';
|
||||
if (issue.reviewers.length) {
|
||||
issue.reviewersNames = issue.reviewers
|
||||
.map((item) => item.author.login)
|
||||
.join('-');
|
||||
}
|
||||
|
||||
issues.push(issue);
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
renderBoolAsYesOrNo(issue, prop) {
|
||||
let comp = <span className="no">NO</span>;
|
||||
if (issue[prop] === true) {
|
||||
comp = <span className="yes">YES</span>;
|
||||
}
|
||||
return comp;
|
||||
}
|
||||
|
||||
setArrow(column) {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
let className = 'sort-direction';
|
||||
if (qs.sort === column) {
|
||||
className = `${className} ${qs.dir === 'asc' ? ' asc' : ' desc'}`;
|
||||
}
|
||||
return className;
|
||||
}
|
||||
|
||||
async componentDidUpdate(prevProps, prevState) {
|
||||
await this.updateComponentData(prevProps, prevState);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.updateComponentData();
|
||||
}
|
||||
|
||||
async updateComponentData(prevProps, prevState) {
|
||||
const { match } = this.props;
|
||||
const { milestone, year, month, day } = match.params;
|
||||
|
||||
// Check if we need to recalculate anything.
|
||||
if (prevProps && prevProps.match) {
|
||||
const prevMatch = prevProps.match;
|
||||
if (
|
||||
prevMatch.params.milestone === milestone &&
|
||||
prevMatch.params.year === year &&
|
||||
prevMatch.params.month === month &&
|
||||
prevMatch.params.day === day
|
||||
) {
|
||||
console.log('No changes to milestone props, skipping update');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top when new data loads.
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
let milestoneTag;
|
||||
let milestonePagination;
|
||||
|
||||
if (milestone === 'latest') {
|
||||
milestonePagination = getMilestonePagination({
|
||||
startDate: getNextMilestone(),
|
||||
});
|
||||
milestoneTag = milestonePagination.current;
|
||||
// Update the URL from '/milestones/latest/...' to the permalink URL.
|
||||
this.props.history.push(
|
||||
`/milestones/${
|
||||
milestonePagination.current
|
||||
}/${this.getCurrentSortQueryString()}`,
|
||||
);
|
||||
} else {
|
||||
milestoneTag = `${year}.${month}.${day}`;
|
||||
milestonePagination = getMilestonePagination({
|
||||
startDate: new Date(year, month - 1, day),
|
||||
});
|
||||
}
|
||||
|
||||
const milestoneIssues = await Client.getMilestoneIssues(milestoneTag);
|
||||
|
||||
this.setState({
|
||||
pagination: milestonePagination,
|
||||
milestoneIssues: milestoneIssues.data
|
||||
? this.formatData(milestoneIssues.data.milestone_issues.results)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
sortConfig = {
|
||||
assignee: {},
|
||||
priority: {},
|
||||
title: {},
|
||||
repo: {},
|
||||
updatedAt: {
|
||||
sortFunc: dateSort,
|
||||
},
|
||||
hasProject: {},
|
||||
state: {},
|
||||
reviewersNames: {},
|
||||
};
|
||||
|
||||
sortData({ columnKey, direction } = {}) {
|
||||
const data = this.state.milestoneIssues;
|
||||
if (!data) {
|
||||
console.debug('No data yet, bailing');
|
||||
return data;
|
||||
}
|
||||
if (!Object.keys(this.sortConfig).includes(columnKey)) {
|
||||
console.debug(
|
||||
`"${columnKey}" does not match one of "${Object.keys(
|
||||
this.sortConfig,
|
||||
).join(', ')}"`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
if (!['desc', 'asc'].includes(direction)) {
|
||||
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
|
||||
return data;
|
||||
}
|
||||
|
||||
const sortFunc = this.sortConfig[columnKey].sortFunc || alphaSort;
|
||||
const sorted = [].concat(data).sort(sortFunc(columnKey));
|
||||
|
||||
// Reverse for desc.
|
||||
if (direction === 'desc') {
|
||||
sorted.reverse();
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
getCurrentSortQueryString() {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
return `?${queryString.stringify({
|
||||
dir: qs.dir || defaultSortDir,
|
||||
sort: qs.sort || defaultSort,
|
||||
})}`;
|
||||
}
|
||||
|
||||
renderHeaderLink(column, name) {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
const sort = qs.sort;
|
||||
let linkDir = 'desc';
|
||||
let classDir = qs.dir === 'asc' ? 'asc' : 'desc';
|
||||
let className = 'sort-direction';
|
||||
if (sort === column) {
|
||||
linkDir = qs.dir === 'desc' ? 'asc' : 'desc';
|
||||
className = `${className} ${classDir}`;
|
||||
}
|
||||
const query = `?${queryString.stringify({
|
||||
dir: linkDir,
|
||||
sort: column,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<LinkContainer to={query}>
|
||||
<a className={className} href={query}>
|
||||
{name}
|
||||
</a>
|
||||
</LinkContainer>
|
||||
);
|
||||
}
|
||||
|
||||
renderAssignee(issue) {
|
||||
if (issue.assignees.nodes.length) {
|
||||
const issueAssignee = issue.assignees.nodes[0];
|
||||
return (
|
||||
<span>
|
||||
<img className="avatar" src={issueAssignee.avatarUrl} alt="" />{' '}
|
||||
{issueAssignee.login}
|
||||
</span>
|
||||
);
|
||||
} else if (issue.assignee === '01_contributor') {
|
||||
return (
|
||||
<span className="contributor">
|
||||
<Octicon icon={Heart} verticalAlign="middle" /> Contributor
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="unassigned">
|
||||
<Octicon icon={Person} verticalAlign="middle" /> Unassigned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
renderReviewers(issue) {
|
||||
const reviewers = [];
|
||||
issue.reviewers.forEach((item) => {
|
||||
reviewers.push(
|
||||
<React.Fragment key={`${issue.number}-${item.author.login}`}>
|
||||
<a href={item.prLink} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className="avatar"
|
||||
src={item.author.avatarUrl}
|
||||
title={`Reviewed by ${item.author.login}`}
|
||||
alt=""
|
||||
/>
|
||||
</a>
|
||||
</React.Fragment>,
|
||||
);
|
||||
});
|
||||
return reviewers;
|
||||
}
|
||||
|
||||
renderRows(data) {
|
||||
const rows = [];
|
||||
const colSpan = 7;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>Loading...</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan}>
|
||||
<p>There are no issues associated with this milestone yet.</p>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
const issue = data[i] || {};
|
||||
rows.push(
|
||||
<tr key={`issue-${i}`}>
|
||||
<td className="assignee">{this.renderAssignee(issue)}</td>
|
||||
<td>
|
||||
<span className={issue.priority || 'unprioritized'}>
|
||||
{issue.priority ? (
|
||||
issue.priority.toUpperCase()
|
||||
) : (
|
||||
<Octicon icon={Alert} />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="issueLink"
|
||||
href={issue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>#{issue.number}:</strong> {issue.title}{' '}
|
||||
<Octicon icon={Link} verticalAlign="middle" />
|
||||
</a>
|
||||
{issue.hasProject ? (
|
||||
<a
|
||||
href={issue.projectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="projectLink"
|
||||
>
|
||||
{issue.projectName}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{issue.repository.name.replace('addons-', '')}</td>
|
||||
<td>
|
||||
<TimeAgo date={issue.updatedAt} />
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="label"
|
||||
style={{
|
||||
backgroundColor: issue.stateLabelColor,
|
||||
color: issue.stateLabelTextColor,
|
||||
}}
|
||||
>
|
||||
{issue.stateLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="reviewers">{this.renderReviewers(issue)}</td>
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { location } = this.props;
|
||||
const qs = queryString.parse(location.search);
|
||||
const milestone = this.state.pagination.start || 'Loading...';
|
||||
let data = this.state.milestoneIssues;
|
||||
if (qs.sort) {
|
||||
data = this.sortData({ columnKey: qs.sort, direction: qs.dir });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Milestones">
|
||||
<Helmet>
|
||||
<title>Milestones</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/milestones/${
|
||||
this.state.pagination.prevFromStart
|
||||
}/${this.getCurrentSortQueryString()}`}
|
||||
active={false}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="prev" className="previous">
|
||||
Previous
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/milestones/${
|
||||
this.state.pagination.nextFromStart
|
||||
}/${this.getCurrentSortQueryString()}`}
|
||||
active={false}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="next" className="next">
|
||||
Next
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/milestones/${
|
||||
this.state.pagination.current
|
||||
}/${this.getCurrentSortQueryString()}`}
|
||||
isActive={(match, location) => {
|
||||
return (
|
||||
location.pathname.indexOf(this.state.pagination.current) >
|
||||
-1 || location.pathname.indexOf('latest') > -1
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Nav.Link eventKey="current" className="current">
|
||||
Current Milestone
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Container as="main" bg="light">
|
||||
<h1>Issues for milestone: {milestone}</h1>
|
||||
<Table responsive hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="assignees">
|
||||
{this.renderHeaderLink('assignee', 'Assignee')}
|
||||
</th>
|
||||
<th>{this.renderHeaderLink('priority', 'Priority')}</th>
|
||||
<th className="issue">
|
||||
{this.renderHeaderLink('title', 'Issue')}
|
||||
</th>
|
||||
<th className="repo">
|
||||
{this.renderHeaderLink('repo', 'Repo')}
|
||||
</th>
|
||||
<th className="last-updated">
|
||||
{this.renderHeaderLink('updatedAt', 'Last Update')}
|
||||
</th>
|
||||
<th className="state">
|
||||
{this.renderHeaderLink('state', 'State')}
|
||||
</th>
|
||||
<th>{this.renderHeaderLink('reviewersNames', 'Reviewers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{this.renderRows(data)}</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Milestones;
|
|
@ -1,69 +0,0 @@
|
|||
.Milestones {
|
||||
th.assignees {
|
||||
min-width: 10.5em;
|
||||
}
|
||||
|
||||
th.repo {
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
th.issue {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.label {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
min-width: 8em;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.assignee,
|
||||
.reviewers {
|
||||
img,
|
||||
svg {
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
display: inline-block;
|
||||
height: 25px;
|
||||
margin-bottom: 2px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.contributor svg {
|
||||
box-sizing: border-box;
|
||||
color: red;
|
||||
height: 28px;
|
||||
padding-top: 2px;
|
||||
position: relative;
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewers {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.projectLink {
|
||||
color: #666;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
|
||||
svg {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.issueLink {
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import Milestones from './Milestones';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const fakeLocation = {
|
||||
pathname: '/2019.04.25/',
|
||||
};
|
||||
|
||||
const fakeMatch = {
|
||||
params: {
|
||||
year: '2019',
|
||||
month: '04',
|
||||
day: '25',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Milestones', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mock(/\/api\/milestone-issues\//, testData.milestoneIssues);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should render a milestone table', async () => {
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Milestones location={fakeLocation} match={fakeMatch} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Milestones').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const issueData = instance.state.milestoneIssues;
|
||||
const expectedNumberOfRows = Object.keys(issueData).length;
|
||||
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
|
||||
});
|
||||
});
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const NotFound = () => (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>404 Not Found | Add-ons PM</title>
|
||||
</Helmet>
|
||||
<main className="container">
|
||||
<br />
|
||||
<h1>Page Not Found</h1>
|
||||
<p>Sorry we can't find the page you were looking for</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default NotFound;
|
|
@ -1,8 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<NotFound />, div);
|
||||
});
|
|
@ -1,490 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { sanitize } from './utils';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardDeck,
|
||||
Container,
|
||||
Nav,
|
||||
Navbar,
|
||||
NavDropdown,
|
||||
Row,
|
||||
ProgressBar,
|
||||
} from 'react-bootstrap';
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
|
||||
import TimeAgo from 'react-timeago';
|
||||
import Octicon, { Clock, Project } from '@githubprimer/octicons-react';
|
||||
|
||||
import Client from './Client';
|
||||
import Engineer from './components/Engineer';
|
||||
|
||||
class Projects extends Component {
|
||||
state = {
|
||||
projects: {
|
||||
data: null,
|
||||
},
|
||||
team: {
|
||||
data: null,
|
||||
},
|
||||
};
|
||||
|
||||
async getProjects(year, quarter) {
|
||||
return await Client.getProjects(year, quarter);
|
||||
}
|
||||
|
||||
async getTeam() {
|
||||
return await Client.getTeam();
|
||||
}
|
||||
|
||||
async componentDidUpdate(prevProps, prevState) {
|
||||
await this.updateComponentData(prevProps, prevState);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.updateComponentData();
|
||||
}
|
||||
|
||||
async updateComponentData(prevProps, prevState) {
|
||||
const { match } = this.props;
|
||||
let { year, quarter } = match.params;
|
||||
|
||||
// Check if we need to recalculate anything.
|
||||
if (prevProps && prevProps.match) {
|
||||
const prevMatch = prevProps.match;
|
||||
if (
|
||||
prevMatch.params.year === year &&
|
||||
prevMatch.params.quarter === quarter
|
||||
) {
|
||||
console.log('No changes to project props, skipping update');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top when new data loads.
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
if (year === 'latest') {
|
||||
// Work out the current year/milestone.
|
||||
const {
|
||||
year: currentYear,
|
||||
quarter: currentQuarter,
|
||||
} = this.getCurrentQuarter();
|
||||
year = currentYear;
|
||||
quarter = currentQuarter;
|
||||
|
||||
// Update the url.
|
||||
this.props.history.push(`/projects/${year}/${quarter}/`);
|
||||
}
|
||||
|
||||
// Fetch all data in parallel.
|
||||
const [projectData, teamData] = await Promise.all([
|
||||
this.getProjects(year, quarter),
|
||||
this.getTeam(),
|
||||
]);
|
||||
|
||||
this.setState({
|
||||
projects: this.buildMetaData(projectData),
|
||||
team: teamData,
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentQuarter() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const quarter = `Q${Math.floor((today.getMonth() + 3) / 3)}`;
|
||||
return {
|
||||
year,
|
||||
quarter,
|
||||
};
|
||||
}
|
||||
|
||||
getPrevQuarter() {
|
||||
const { match } = this.props;
|
||||
let { quarter, year } = match.params;
|
||||
|
||||
if (!quarter || !year) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const numericQuarter = quarter.substr(1);
|
||||
let newQuarter = parseInt(numericQuarter, 10);
|
||||
let newYear = parseInt(year, 10);
|
||||
|
||||
if (newQuarter > 1) {
|
||||
newQuarter = newQuarter - 1;
|
||||
} else if (newQuarter === 1) {
|
||||
newQuarter = 4;
|
||||
newYear = newYear - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
year: newYear,
|
||||
quarter: `Q${newQuarter}`,
|
||||
};
|
||||
}
|
||||
|
||||
getNextQuarter() {
|
||||
const { match } = this.props;
|
||||
let { quarter, year } = match.params;
|
||||
|
||||
if (!quarter || !year) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const numericQuarter = quarter.substr(1);
|
||||
let newYear = parseInt(year, 10);
|
||||
let newQuarter = parseInt(numericQuarter, 10);
|
||||
|
||||
if (newQuarter < 4) {
|
||||
newQuarter = newQuarter + 1;
|
||||
} else if (newQuarter === 4) {
|
||||
newQuarter = 1;
|
||||
newYear = newYear + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
year: newYear,
|
||||
quarter: `Q${newQuarter}`,
|
||||
};
|
||||
}
|
||||
|
||||
projectSort(a, b) {
|
||||
const goalTypeA = a.meta.goalType ? a.meta.goalType : 'unclassified';
|
||||
const goalTypeB = b.meta.goalType ? b.meta.goalType : 'unclassified';
|
||||
return goalTypeA < goalTypeB ? -1 : goalTypeA > goalTypeB ? 1 : 0;
|
||||
}
|
||||
|
||||
parseProjectMeta(HTML) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(HTML, 'text/html');
|
||||
const engineers = doc
|
||||
.evaluate(
|
||||
"//details//dl/dt[contains(., 'Engineering')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
)
|
||||
.stringValue.replace(/ ?@/g, '')
|
||||
.split(',');
|
||||
const goalType = doc
|
||||
.evaluate(
|
||||
"//details//dl/dt[contains(., 'Goal Type')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
)
|
||||
.stringValue.toLowerCase();
|
||||
const size = doc.evaluate(
|
||||
"//details//dl/dt[contains(., 'Size')]/following-sibling::dd[1]",
|
||||
doc,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
).stringValue;
|
||||
const details = doc.querySelector('details');
|
||||
if (details) {
|
||||
// Remove the meta data HTML from the doc.
|
||||
// details.parentNode.removeChild(details);
|
||||
}
|
||||
return [{ engineers, goalType, size }, doc.documentElement.outerHTML];
|
||||
}
|
||||
|
||||
// This function pre-computes counts and extracts metadata prior to the data being
|
||||
// added to the state. This way we're not running the same code over and over again
|
||||
// during the render.
|
||||
buildMetaData(projectData) {
|
||||
const newProjectData = { ...projectData };
|
||||
const augmentedProjects = newProjectData.data.organization.projects.nodes.map(
|
||||
(project, idx) => {
|
||||
const [meta, updatedHTML] = this.parseProjectMeta(project.bodyHTML);
|
||||
project.bodyHTML = updatedHTML;
|
||||
const todoColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'To do';
|
||||
});
|
||||
const inProgressColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'In progress';
|
||||
});
|
||||
const doneColumn = project.columns.edges.find((column) => {
|
||||
return column.node.name === 'Done';
|
||||
});
|
||||
const todoCount =
|
||||
(todoColumn && parseInt(todoColumn.node.cards.totalCount, 10)) || 0;
|
||||
const inProgressCount =
|
||||
(inProgressColumn &&
|
||||
parseInt(inProgressColumn.node.cards.totalCount, 10)) ||
|
||||
0;
|
||||
const doneCount =
|
||||
(doneColumn && parseInt(doneColumn.node.cards.totalCount, 10)) || 0;
|
||||
const totalCount = todoCount + inProgressCount + doneCount;
|
||||
|
||||
// console.log(project.name, { todoCount, inProgressCount, doneCount, totalCount });
|
||||
|
||||
const donePerc = totalCount ? (100 / totalCount) * doneCount : 0;
|
||||
const inProgressPerc = totalCount
|
||||
? (100 / totalCount) * inProgressCount
|
||||
: 0;
|
||||
project.meta = {
|
||||
...meta,
|
||||
todoCount,
|
||||
inProgressCount,
|
||||
doneCount,
|
||||
donePerc,
|
||||
inProgressPerc,
|
||||
};
|
||||
return project;
|
||||
},
|
||||
);
|
||||
newProjectData.data.organization.projects.nodes = augmentedProjects;
|
||||
return newProjectData;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { match } = this.props;
|
||||
const { year, quarter, projectType, engineer } = match.params;
|
||||
const projectsData = this.state.projects;
|
||||
const teamData = this.state.team;
|
||||
|
||||
let projects = null;
|
||||
let currentProjectType = null;
|
||||
if (projectsData.data) {
|
||||
projectsData.data.organization.projects.nodes.sort(this.projectSort);
|
||||
projects = projectsData.data.organization.projects.nodes.map(
|
||||
(project, idx) => {
|
||||
if (projectType && projectType !== null) {
|
||||
if (!RegExp(projectType, 'i').test(project.meta.goalType)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let teamMembers = [];
|
||||
if (teamData.data) {
|
||||
for (const engineer of project.meta.engineers) {
|
||||
const teamMembersToSearch = [
|
||||
...teamData.data.organization.team.members.nodes,
|
||||
...teamData.data.organization.outreachy.members.nodes,
|
||||
];
|
||||
const foundMember = teamMembersToSearch.find((item) => {
|
||||
return item.login.toLowerCase() === engineer.toLowerCase();
|
||||
});
|
||||
if (foundMember) {
|
||||
teamMembers.push(foundMember);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
engineer &&
|
||||
project.meta.engineers &&
|
||||
!project.meta.engineers.includes(engineer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectNode = (
|
||||
<div className="card-wrapper" key={idx}>
|
||||
{!projectType && currentProjectType === null ? (
|
||||
<h2 className="project-type">Primary</h2>
|
||||
) : null}
|
||||
{!projectType &&
|
||||
currentProjectType === 'primary' &&
|
||||
project.meta.goalType === 'secondary' ? (
|
||||
<h2 className="project-type">Secondary</h2>
|
||||
) : null}
|
||||
<Card
|
||||
bg={project.meta.goalType === 'primary' ? 'muted' : 'light'}
|
||||
>
|
||||
<Card.Header as="h2">
|
||||
<span>
|
||||
<Octicon icon={Project} verticalAlign="middle" />
|
||||
{project.name}
|
||||
</span>
|
||||
<div>
|
||||
{teamMembers.map((member) => {
|
||||
return (
|
||||
<Engineer
|
||||
key={member.login + project.name}
|
||||
member={member}
|
||||
project={project}
|
||||
year={year}
|
||||
quarter={quarter}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<ProgressBar>
|
||||
<ProgressBar
|
||||
variant="success"
|
||||
now={project.meta.donePerc}
|
||||
key={1}
|
||||
/>
|
||||
<ProgressBar
|
||||
variant="warning"
|
||||
now={project.meta.inProgressPerc}
|
||||
key={2}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<Card.Body
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(project.bodyHTML),
|
||||
}}
|
||||
/>
|
||||
<Card.Footer bg="light">
|
||||
<span className="updated float-left">
|
||||
<Octicon icon={Clock} /> Updated{' '}
|
||||
<TimeAgo date={project.updatedAt} />
|
||||
</span>
|
||||
<Button
|
||||
href={project.url}
|
||||
size="sm"
|
||||
className="float-right"
|
||||
variant="outline-primary"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
View Project
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
|
||||
currentProjectType = project.meta.goalType;
|
||||
return projectNode;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Filter null elements. This avoids a case where we have
|
||||
// a projects list that looks like [null, null].
|
||||
if (projects && projects.length) {
|
||||
projects = projects.filter((el) => {
|
||||
return el !== null;
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
year: currentYear,
|
||||
quarter: currentQuarter,
|
||||
} = this.getCurrentQuarter();
|
||||
const { year: prevYear, quarter: prevQuarter } = this.getPrevQuarter();
|
||||
const { year: nextYear, quarter: nextQuarter } = this.getNextQuarter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Projects</title>
|
||||
</Helmet>
|
||||
<Navbar
|
||||
variant="muted"
|
||||
bg="light"
|
||||
className="shadow-sm d-flex justify-content-between"
|
||||
sticky="top"
|
||||
>
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/projects/${prevYear}/${prevQuarter}/`}
|
||||
active={false}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="prev" className="previous">
|
||||
Previous
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/projects/${nextYear}/${nextQuarter}/`}
|
||||
active={false}
|
||||
exact
|
||||
>
|
||||
<Nav.Link eventKey="next" className="next">
|
||||
Next
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
|
||||
<Nav variant="pills">
|
||||
<Nav.Item>
|
||||
<LinkContainer
|
||||
to={`/projects/${currentYear}/${currentQuarter}/`}
|
||||
exact
|
||||
>
|
||||
<Nav.Link
|
||||
eventKey="current"
|
||||
active={
|
||||
this.props.location.pathname.indexOf(
|
||||
`/projects/${currentYear}/${currentQuarter}/`,
|
||||
) === 0
|
||||
}
|
||||
>
|
||||
Current Quarter
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
|
||||
<NavDropdown className="filters" title="Filters" alignRight>
|
||||
<LinkContainer to={`/projects/${year}/${quarter}/`} exact>
|
||||
<NavDropdown.Item
|
||||
eventKey="all"
|
||||
href={`/projects/${year}/${quarter}/`}
|
||||
>
|
||||
All
|
||||
</NavDropdown.Item>
|
||||
</LinkContainer>
|
||||
<LinkContainer to={`/projects/${year}/${quarter}/primary/`} exact>
|
||||
<NavDropdown.Item
|
||||
eventKey="primary"
|
||||
href={`/projects/${year}/${quarter}/primary/`}
|
||||
>
|
||||
Primary
|
||||
</NavDropdown.Item>
|
||||
</LinkContainer>
|
||||
<LinkContainer
|
||||
to={`/projects/${year}/${quarter}/secondary/`}
|
||||
exact
|
||||
>
|
||||
<NavDropdown.Item
|
||||
eventKey="secondary"
|
||||
href={`/projects/${year}/${quarter}/secondary/`}
|
||||
>
|
||||
Secondary
|
||||
</NavDropdown.Item>
|
||||
</LinkContainer>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
|
||||
<Container fluid>
|
||||
<Row>
|
||||
<Container as="main" bg="light">
|
||||
<h1>
|
||||
Projects for {quarter} {year}{' '}
|
||||
{projectType
|
||||
? `(${projectType})`
|
||||
: engineer
|
||||
? `(${engineer})`
|
||||
: '(All)'}
|
||||
</h1>
|
||||
{projectsData.data === null ? <p>Loading...</p> : null}
|
||||
{projects && projects.length ? (
|
||||
<CardDeck>{projects}</CardDeck>
|
||||
) : projects && projects.length === 0 ? (
|
||||
<p>There are no Projects available for this quarter yet</p>
|
||||
) : null}
|
||||
</Container>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Projects;
|
|
@ -1,165 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import Projects from './Projects';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const fakeLocation = {
|
||||
pathname: '/2018/Q3/',
|
||||
};
|
||||
|
||||
const fakeMatch = {
|
||||
params: {
|
||||
year: '2018',
|
||||
quarter: 'Q3',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Projects Page', () => {
|
||||
beforeEach(() => {
|
||||
// Mock API data
|
||||
fetchMock.mock(/\/api\/team\//, testData.team);
|
||||
fetchMock.mock(/\/api\/projects\//, testData.projects);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should render some projects', async () => {
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={fakeMatch} location={fakeLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
const projectData = instance.state.projects;
|
||||
const expectedNumberOfProjects =
|
||||
projectData.data.organization.projects.nodes.length;
|
||||
expect(wrapper.find('.card-wrapper')).toHaveLength(
|
||||
expectedNumberOfProjects,
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter projects by type', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = { ...fakeMatch.params, projectType: 'primary' };
|
||||
const filteredLocation = { pathname: '/2018/Q3/primary/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.card-wrapper')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter projects by engineer', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = { ...fakeMatch.params, engineer: 'testuser' };
|
||||
const filteredLocation = { pathname: '/2018/Q3/testuser/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
await instance.componentDidMount();
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.card-wrapper')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('getNextQuarter()', () => {
|
||||
it('should provide the right project link data for 2019/Q3', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = {
|
||||
...fakeMatch.params,
|
||||
year: '2019',
|
||||
quarter: 'Q3',
|
||||
};
|
||||
const filteredLocation = { pathname: '/2019/Q3/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
const { year, quarter } = instance.getNextQuarter();
|
||||
expect(year).toBe(2019);
|
||||
expect(quarter).toBe('Q4');
|
||||
});
|
||||
|
||||
it('should provide the right project link data for 2019/Q4', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = {
|
||||
...fakeMatch.params,
|
||||
year: '2019',
|
||||
quarter: 'Q4',
|
||||
};
|
||||
const filteredLocation = { pathname: '/2019/Q4/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
const { year, quarter } = instance.getNextQuarter();
|
||||
expect(year).toBe(2020);
|
||||
expect(quarter).toBe('Q1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrevQuarter()', () => {
|
||||
it('should provide the right project link data for 2019/Q3', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = {
|
||||
...fakeMatch.params,
|
||||
year: '2019',
|
||||
quarter: 'Q3',
|
||||
};
|
||||
const filteredLocation = { pathname: '/2019/Q3/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
const { year, quarter } = instance.getPrevQuarter();
|
||||
expect(year).toBe(2019);
|
||||
expect(quarter).toBe('Q2');
|
||||
});
|
||||
|
||||
it('should provide the right project link data for 2019/Q1', async () => {
|
||||
const filteredMatch = { ...fakeMatch };
|
||||
filteredMatch.params = {
|
||||
...fakeMatch.params,
|
||||
year: '2019',
|
||||
quarter: 'Q1',
|
||||
};
|
||||
const filteredLocation = { pathname: '/2019/Q1/' };
|
||||
const wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Projects match={filteredMatch} location={filteredLocation} />
|
||||
</MemoryRouter>,
|
||||
{ disableLifecycleMethods: true },
|
||||
);
|
||||
const instance = wrapper.find('Projects').instance();
|
||||
const { year, quarter } = instance.getPrevQuarter();
|
||||
expect(year).toBe(2018);
|
||||
expect(quarter).toBe('Q4');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,30 +0,0 @@
|
|||
.outer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
circle {
|
||||
fill: #3db8a4;
|
||||
}
|
||||
|
||||
text {
|
||||
fill: #fff;
|
||||
font-family: sans-serif;
|
||||
font-size: 3.5rem;
|
||||
text-shadow: -1px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.warning circle {
|
||||
fill: rgb(222, 80, 41);
|
||||
}
|
||||
|
||||
.total circle {
|
||||
fill: #45a1ff;
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Image } from 'react-bootstrap';
|
||||
|
||||
export default function Engineer(props) {
|
||||
const { member, year, quarter } = props;
|
||||
return (
|
||||
<Link to={`/projects/${year}/${quarter}/${member.login.toLowerCase()}/`}>
|
||||
<Image
|
||||
src={member.avatarUrl}
|
||||
title={member.login}
|
||||
alt={member.login}
|
||||
roundedCircle
|
||||
className="float-right eng-image"
|
||||
height="35"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
/* global testData */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import Engineer from './Engineer';
|
||||
|
||||
describe('Projects Page', () => {
|
||||
it('should lowercase user slug in links', async () => {
|
||||
const fakeProps = {
|
||||
member: testData.team.data.organization.team.members.nodes[1],
|
||||
quarter: 'Q3',
|
||||
year: '2018',
|
||||
};
|
||||
const eng = shallow(<Engineer {...fakeProps} />);
|
||||
expect(eng.find('Link').prop('to')).toBe(
|
||||
'/projects/2018/Q3/team-testuser-2/',
|
||||
);
|
||||
});
|
||||
});
|
Двоичные данные
src/client/images/felt.png
Двоичные данные
src/client/images/felt.png
Двоичный файл не отображается.
До Ширина: | Высота: | Размер: 129 KiB |
|
@ -1,34 +0,0 @@
|
|||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home {
|
||||
&.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
clear: both;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
float: right;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.octicon {
|
||||
box-sizing: content-box;
|
||||
padding: 0 20px 20px 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-left: 10px;
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/* Photon Colors SCSS Variables v3.2.0 */
|
||||
|
||||
$magenta-50: #ff1ad9;
|
||||
$magenta-60: #ed00b5;
|
||||
$magenta-70: #b5007f;
|
||||
$magenta-80: #7d004f;
|
||||
$magenta-90: #440027;
|
||||
|
||||
$purple-30: #c069ff;
|
||||
$purple-40: #ad3bff;
|
||||
$purple-50: #9400ff;
|
||||
$purple-60: #8000d7;
|
||||
$purple-70: #6200a4;
|
||||
$purple-80: #440071;
|
||||
$purple-90: #25003e;
|
||||
|
||||
$blue-40: #45a1ff;
|
||||
$blue-50: #0a84ff;
|
||||
$blue-50-a30: rgba(10, 132, 255, 0.3);
|
||||
$blue-60: #0060df;
|
||||
$blue-70: #003eaa;
|
||||
$blue-80: #002275;
|
||||
$blue-90: #000f40;
|
||||
|
||||
$teal-50: #00feff;
|
||||
$teal-60: #00c8d7;
|
||||
$teal-70: #008ea4;
|
||||
$teal-80: #005a71;
|
||||
$teal-90: #002d3e;
|
||||
|
||||
$green-50: #30e60b;
|
||||
$green-60: #12bc00;
|
||||
$green-70: #058b00;
|
||||
$green-80: #006504;
|
||||
$green-90: #003706;
|
||||
|
||||
$yellow-50: #ffe900;
|
||||
$yellow-60: #d7b600;
|
||||
$yellow-70: #a47f00;
|
||||
$yellow-80: #715100;
|
||||
$yellow-90: #3e2800;
|
||||
|
||||
$red-50: #ff0039;
|
||||
$red-60: #d70022;
|
||||
$red-70: #a4000f;
|
||||
$red-80: #5a0002;
|
||||
$red-90: #3e0200;
|
||||
|
||||
$orange-50: #ff9400;
|
||||
$orange-60: #d76e00;
|
||||
$orange-70: #a44900;
|
||||
$orange-80: #712b00;
|
||||
$orange-90: #3e1300;
|
||||
|
||||
$grey-10: #f9f9fa;
|
||||
$grey-10-a10: rgba(249, 249, 250, 0.1);
|
||||
$grey-10-a20: rgba(249, 249, 250, 0.2);
|
||||
$grey-10-a40: rgba(249, 249, 250, 0.4);
|
||||
$grey-10-a60: rgba(249, 249, 250, 0.6);
|
||||
$grey-10-a80: rgba(249, 249, 250, 0.8);
|
||||
$grey-20: #ededf0;
|
||||
$grey-30: #d7d7db;
|
||||
$grey-40: #b1b1b3;
|
||||
$grey-50: #737373;
|
||||
$grey-60: #4a4a4f;
|
||||
$grey-70: #38383d;
|
||||
$grey-80: #2a2a2e;
|
||||
$grey-90: #0c0c0d;
|
||||
$grey-90-a05: rgba(12, 12, 13, 0.05);
|
||||
$grey-90-a10: rgba(12, 12, 13, 0.1);
|
||||
$grey-90-a20: rgba(12, 12, 13, 0.2);
|
||||
$grey-90-a30: rgba(12, 12, 13, 0.3);
|
||||
$grey-90-a40: rgba(12, 12, 13, 0.4);
|
||||
$grey-90-a50: rgba(12, 12, 13, 0.5);
|
||||
$grey-90-a60: rgba(12, 12, 13, 0.6);
|
||||
$grey-90-a70: rgba(12, 12, 13, 0.7);
|
||||
$grey-90-a80: rgba(12, 12, 13, 0.8);
|
||||
$grey-90-a90: rgba(12, 12, 13, 0.9);
|
||||
|
||||
$ink-70: #363959;
|
||||
$ink-80: #202340;
|
||||
$ink-90: #0f1126;
|
||||
|
||||
$white-100: #fff;
|
|
@ -1,143 +0,0 @@
|
|||
import { oneLineTrim } from 'common-tags';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// Polyfill padStart if it's not available.
|
||||
if (!String.prototype.padStart) {
|
||||
console.log('Polyfilling padStart');
|
||||
/* eslint-disable-next-line no-extend-native */
|
||||
String.prototype.padStart = function (length, repeated) {
|
||||
return repeated.repeat(length).substring(0, length - this.length) + this;
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLabel(issueLabels, labelOrLabelList) {
|
||||
const labels = issueLabels || [];
|
||||
if (Array.isArray(labelOrLabelList)) {
|
||||
return labels.some((item) => labelOrLabelList.includes(item.name));
|
||||
}
|
||||
return !!labels.find((label) => label.name === labelOrLabelList);
|
||||
}
|
||||
|
||||
export function hasLabelContainingString(issueLabels, string) {
|
||||
const labels = issueLabels || [];
|
||||
const rx = new RegExp(string);
|
||||
return !!labels.find((label) => rx.test(label.name));
|
||||
}
|
||||
|
||||
export function dateSort(key) {
|
||||
return (a, b) => {
|
||||
return new Date(a[key]) - new Date(b[key]);
|
||||
};
|
||||
}
|
||||
|
||||
export function numericSort(key) {
|
||||
return (a, b) => {
|
||||
return a[key] - b[key];
|
||||
};
|
||||
}
|
||||
|
||||
export function alphaSort(key) {
|
||||
return (a, b) => {
|
||||
var strA = a[key].toUpperCase();
|
||||
var strB = b[key].toUpperCase();
|
||||
if (strA < strB) {
|
||||
return -1;
|
||||
}
|
||||
if (strA > strB) {
|
||||
return 1;
|
||||
}
|
||||
// names must be equal
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
// This function should return the next nearest release
|
||||
// date including if the release date is today.
|
||||
// dayOfWeek: Sunday is 0, Monday is 1 etc...
|
||||
export function getNextMilestone({
|
||||
dayOfWeek = 4,
|
||||
startDate = new Date(),
|
||||
} = {}) {
|
||||
if (startDate.getDay() === dayOfWeek) {
|
||||
return startDate;
|
||||
}
|
||||
const resultDate = new Date(startDate.getTime());
|
||||
resultDate.setDate(
|
||||
startDate.getDate() + ((7 + dayOfWeek - startDate.getDay() - 1) % 7) + 1,
|
||||
);
|
||||
return resultDate;
|
||||
}
|
||||
|
||||
// Formats a date object into a milestone format YYYY.MM.DD
|
||||
// Handles zero filling so 2019.1.1 will be 2019.01.01
|
||||
export function formatDateToMilestone(date) {
|
||||
return oneLineTrim`${date.getFullYear()}.
|
||||
${(date.getMonth() + 1).toString().padStart(2, '0')}.
|
||||
${date.getDate().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function getMilestonePagination({
|
||||
dayOfWeek = 4,
|
||||
startDate = new Date(),
|
||||
} = {}) {
|
||||
// The nearest release milestone to the starting point.
|
||||
let nextMilestone = getNextMilestone({ dayOfWeek, startDate });
|
||||
const prev = new Date(
|
||||
nextMilestone.getFullYear(),
|
||||
nextMilestone.getMonth(),
|
||||
nextMilestone.getDate() - 7,
|
||||
);
|
||||
|
||||
// Set next Milestone to 7 days time if we're starting on current milestone date already.
|
||||
if (
|
||||
formatDateToMilestone(startDate) === formatDateToMilestone(nextMilestone)
|
||||
) {
|
||||
nextMilestone = new Date(
|
||||
nextMilestone.getFullYear(),
|
||||
nextMilestone.getMonth(),
|
||||
nextMilestone.getDate() + 7,
|
||||
);
|
||||
}
|
||||
|
||||
// The current milestone closest to today.
|
||||
const currentMilestone = getNextMilestone(dayOfWeek);
|
||||
|
||||
return {
|
||||
// The milestone before the startDate.
|
||||
prevFromStart: formatDateToMilestone(prev),
|
||||
// The startDate milestone (might not be a typical release day).
|
||||
start: formatDateToMilestone(startDate),
|
||||
// The milestone after the startDate.
|
||||
nextFromStart: formatDateToMilestone(nextMilestone),
|
||||
// The current closest milestone to today.
|
||||
current: formatDateToMilestone(currentMilestone),
|
||||
};
|
||||
}
|
||||
|
||||
export function hexToRgb(hex) {
|
||||
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
export function colourIsLight(hex) {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
// Counting the perceptive luminance
|
||||
// human eye favors green color...
|
||||
var a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return a < 0.5;
|
||||
}
|
||||
|
||||
export const sanitize = DOMPurify.sanitize;
|
|
@ -1,186 +0,0 @@
|
|||
import {
|
||||
alphaSort,
|
||||
colourIsLight,
|
||||
dateSort,
|
||||
formatDateToMilestone,
|
||||
getNextMilestone,
|
||||
getMilestonePagination,
|
||||
hasLabel,
|
||||
hasLabelContainingString,
|
||||
hexToRgb,
|
||||
numericSort,
|
||||
sanitize,
|
||||
} from './utils';
|
||||
|
||||
describe('Utils', () => {
|
||||
it('sanitize() sanitizes bad markup', () => {
|
||||
const sanitized = sanitize(
|
||||
'<a href="javascript: alert(document.cookie)">foo</a>',
|
||||
);
|
||||
expect(sanitized).not.toEqual(expect.stringMatching('javascript'));
|
||||
});
|
||||
|
||||
it('sanitize() adds target="_blank" and rel="noopener noreferrer" to links', () => {
|
||||
const sanitized = sanitize('<a href="#whatevs">foo</a>');
|
||||
expect(sanitized).toEqual(
|
||||
expect.stringMatching('rel="noopener noreferrer"'),
|
||||
);
|
||||
expect(sanitized).toEqual(expect.stringMatching('target="_blank"'));
|
||||
});
|
||||
|
||||
it('hexToRgb() converts hex to rgb', () => {
|
||||
const { r, g, b } = hexToRgb('#ffffff');
|
||||
expect(r).toEqual(255);
|
||||
expect(g).toEqual(255);
|
||||
expect(b).toEqual(255);
|
||||
});
|
||||
|
||||
it('colourIsLight() returns useful values', () => {
|
||||
expect(colourIsLight('#ffffff')).toEqual(true);
|
||||
expect(colourIsLight('#000000')).not.toEqual(true);
|
||||
});
|
||||
|
||||
describe('hasLabel()', () => {
|
||||
const fakeIssueLabels = [
|
||||
{ name: 'foo' },
|
||||
{ name: 'fooBar' },
|
||||
{ name: 'something' },
|
||||
];
|
||||
|
||||
it('returns true for exact match', () => {
|
||||
expect(hasLabel(fakeIssueLabels, 'foo')).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false for partial match', () => {
|
||||
expect(hasLabel(fakeIssueLabels, 'thing')).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true for one of list input', () => {
|
||||
expect(hasLabel(fakeIssueLabels, ['foo', 'bar'])).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns false for partial match of list input', () => {
|
||||
expect(hasLabel(fakeIssueLabels, ['thing', 'bar'])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLabelContainingString()', () => {
|
||||
const fakeIssueLabels = [
|
||||
{ name: 'foo' },
|
||||
{ name: 'fooBar' },
|
||||
{ name: 'bar' },
|
||||
{ name: 'baz' },
|
||||
{ name: 'something' },
|
||||
];
|
||||
|
||||
it('returns true for exact match', () => {
|
||||
expect(hasLabelContainingString(fakeIssueLabels, 'foo')).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true for partial match', () => {
|
||||
expect(hasLabelContainingString(fakeIssueLabels, 'thing')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateSort()', () => {
|
||||
const data = [
|
||||
{ date: '2019-03-25T17:27:07Z' },
|
||||
{ date: '2019-03-20T15:51:59Z' },
|
||||
];
|
||||
|
||||
it('sorts dates', () => {
|
||||
const result = [].concat(data).sort(dateSort('date'));
|
||||
expect(result[0]).toEqual(data[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numericSort()', () => {
|
||||
const data = [{ num: 2 }, { num: 1 }];
|
||||
|
||||
it('sorts numbers', () => {
|
||||
const result = [].concat(data).sort(numericSort('num'));
|
||||
expect(result[0]).toEqual(data[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alphaSort()', () => {
|
||||
const data = [
|
||||
{ letters: 'bbcc' },
|
||||
{ letters: 'aabbcc' },
|
||||
{ letters: 'cccddd' },
|
||||
{ letters: 'bbcc' },
|
||||
];
|
||||
|
||||
it('sorts letters', () => {
|
||||
const result = [].concat(data).sort(alphaSort('letters'));
|
||||
expect(result[0]).toEqual(data[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextMilestone()', () => {
|
||||
it('gets the next nearest Thursday', () => {
|
||||
// Note months are zero indexed.
|
||||
const startDate = new Date('2019', '3', '9');
|
||||
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 4 });
|
||||
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.11');
|
||||
});
|
||||
|
||||
it('gets the next nearest Thursday when start date is Thursday', () => {
|
||||
// Note months are zero indexed.
|
||||
const startDate = new Date('2019', '3', '11');
|
||||
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 4 });
|
||||
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.11');
|
||||
});
|
||||
|
||||
it('gets the next nearest Monday', () => {
|
||||
// Note months are zero indexed.
|
||||
const startDate = new Date('2019', '3', '11');
|
||||
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 1 });
|
||||
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateToMilestone()', () => {
|
||||
it('zero fills months and days', () => {
|
||||
const startDate = new Date('2019', '3', '9');
|
||||
expect(formatDateToMilestone(startDate)).toEqual('2019.04.09');
|
||||
});
|
||||
|
||||
it(`doesn't zero fill months and days when not needed`, () => {
|
||||
const startDate = new Date('2019', '11', '15');
|
||||
expect(formatDateToMilestone(startDate)).toEqual('2019.12.15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMilestonePagination()', () => {
|
||||
it('gets pagination based on a specific start date', () => {
|
||||
// Note months are zero indexed.
|
||||
const startDate = new Date('2019', '3', '9');
|
||||
const milestonePagination = getMilestonePagination({
|
||||
startDate,
|
||||
dayOfWeek: 4,
|
||||
});
|
||||
expect(milestonePagination.prevFromStart).toEqual('2019.04.04');
|
||||
expect(milestonePagination.start).toEqual('2019.04.09');
|
||||
expect(milestonePagination.nextFromStart).toEqual('2019.04.11');
|
||||
expect(milestonePagination.current).toEqual(
|
||||
formatDateToMilestone(getNextMilestone()),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets pagination based on a specific start date that matches the dayOfWeek', () => {
|
||||
// Note months are zero indexed.
|
||||
const startDate = new Date('2019', '3', '11');
|
||||
const milestonePagination = getMilestonePagination({
|
||||
startDate,
|
||||
dayOfWeek: 4,
|
||||
});
|
||||
expect(milestonePagination.prevFromStart).toEqual('2019.04.04');
|
||||
expect(milestonePagination.start).toEqual('2019.04.11');
|
||||
expect(milestonePagination.nextFromStart).toEqual('2019.04.18');
|
||||
expect(milestonePagination.current).toEqual(
|
||||
formatDateToMilestone(getNextMilestone()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,44 +0,0 @@
|
|||
import * as constants from './const';
|
||||
|
||||
describe('const.validYearRX', () => {
|
||||
it('should match valid content', () => {
|
||||
expect('2019').toMatch(constants.validYearRX);
|
||||
expect('2020').toMatch(constants.validYearRX);
|
||||
});
|
||||
|
||||
it('should not match invalid content', () => {
|
||||
expect('201113').not.toMatch(constants.validYearRX);
|
||||
expect('1977').not.toMatch(constants.validYearRX);
|
||||
expect('2016').not.toMatch(constants.validYearRX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('const.validQuarterRX', () => {
|
||||
it('should match valid content', () => {
|
||||
expect('Q1').toMatch(constants.validQuarterRX);
|
||||
expect('Q2').toMatch(constants.validQuarterRX);
|
||||
expect('Q3').toMatch(constants.validQuarterRX);
|
||||
expect('Q4').toMatch(constants.validQuarterRX);
|
||||
});
|
||||
|
||||
it('should not match invalid content', () => {
|
||||
expect('Q5').not.toMatch(constants.validQuarterRX);
|
||||
expect('whatever').not.toMatch(constants.validQuarterRX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('const.validMilestoneRX', () => {
|
||||
it('should match valid content', () => {
|
||||
expect('2019.04.04').toMatch(constants.validMilestoneRX);
|
||||
expect('2018.03.12').toMatch(constants.validMilestoneRX);
|
||||
expect('2019.12.31').toMatch(constants.validMilestoneRX);
|
||||
expect('2019.01.01').toMatch(constants.validMilestoneRX);
|
||||
});
|
||||
|
||||
it('should not match invalid content', () => {
|
||||
expect('2016.11.11').not.toMatch(constants.validMilestoneRX);
|
||||
expect('2019.11.32').not.toMatch(constants.validMilestoneRX);
|
||||
expect('2019.13.32').not.toMatch(constants.validMilestoneRX);
|
||||
expect('whatever').not.toMatch(constants.validMilestoneRX);
|
||||
});
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
export const bugzillaIssueCounts = {
|
||||
bug_count: 20,
|
||||
};
|
|
@ -1,28 +0,0 @@
|
|||
export const bugzillaNeedsInfoLocal = {
|
||||
mixedpuppy: {
|
||||
count: 21,
|
||||
url:
|
||||
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1329853,1355239,1373297,1411209,1595610,1605515,1611878,1620100,1627939,1628642,1629734,1633189,1635344,1637059,1645341,1646016,1653841,1654355,1655040,1655353,1656732',
|
||||
},
|
||||
rwu: {
|
||||
count: 13,
|
||||
url:
|
||||
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1402612,1426789,1435473,1490052,1561604,1589896,1595513,1595610,1600556,1602005,1602335,1643558,1650956',
|
||||
},
|
||||
rpl: {
|
||||
count: 6,
|
||||
url:
|
||||
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1433543,1490857,1626252,1637616,1651434,1659190',
|
||||
},
|
||||
zombie: {
|
||||
count: 19,
|
||||
url:
|
||||
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1284938,1398672,1437051,1581859,1594921,1604538,1605518,1612800,1617962,1619140,1625271,1635062,1635598,1635637,1655528,1655898,165606,1656671,1657384',
|
||||
},
|
||||
muffinresearch: { count: 0 },
|
||||
UX: {
|
||||
count: 2,
|
||||
url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1531976,1647056',
|
||||
},
|
||||
JorgeV: { count: 0 },
|
||||
};
|
13
src/index.js
13
src/index.js
|
@ -1,13 +0,0 @@
|
|||
// This file is ignored from coverage
|
||||
// since it's mostly boilerplate. If something
|
||||
// is added here that needs testing please update the
|
||||
// test script in package.json to include it.
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './client/App';
|
||||
|
||||
import 'bootstrap/dist/css/bootstrap.css';
|
||||
import './client/index.scss';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
|
@ -1,163 +0,0 @@
|
|||
// Magically require any env variables defined in a local .env file.
|
||||
require('dotenv').config();
|
||||
// Polyfill fetch.
|
||||
const serverSWR = require('./serverSWR');
|
||||
const queryString = require('query-string');
|
||||
|
||||
const baseAPIURL = 'https://bugzilla.mozilla.org/rest/bug';
|
||||
const baseWEBURL = 'https://bugzilla.mozilla.org/buglist.cgi';
|
||||
const needsInfoURL = `${baseAPIURL}?include_fields=id&f1=flagtypes.name&f2=requestees.login_name&o1=casesubstring&o2=equals&v1=needinfo%3F&v2=`;
|
||||
const webExtOnlyQualifier =
|
||||
'&component=Add-ons Manager&component=Android&component=Compatibility&component=Developer Outreach&component=Developer Tools&component=Experiments&component=Frontend&component=General&component=Request Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions';
|
||||
const priorities = ['--', 'P1', 'P2', 'P3', 'P4', 'P5'];
|
||||
const severities = ['normal', '--', 'N/A', 'S1', 'S2', 'S3', 'S4'];
|
||||
const products = ['Toolkit', 'WebExtensions', 'Firefox'];
|
||||
|
||||
const defaultParams = {
|
||||
count_only: true,
|
||||
resolution: '---',
|
||||
bug_status: ['UNCONFIRMED', 'NEW', 'ASSIGNED', 'REOPENED'],
|
||||
limit: 0,
|
||||
};
|
||||
|
||||
function fetchIssueCount({ priority, product, bug_severity } = {}) {
|
||||
const params = { ...defaultParams, product, priority, bug_severity };
|
||||
if (params.bug_priority && params.bug_severity) {
|
||||
throw new Error('Query only severity or priority independently');
|
||||
}
|
||||
|
||||
if (bug_severity) {
|
||||
delete params.priority;
|
||||
}
|
||||
if (priority) {
|
||||
delete params.bug_severity;
|
||||
}
|
||||
// console.log(JSON.stringify(params));
|
||||
|
||||
if (product === 'Toolkit') {
|
||||
params.component = 'Add-ons Manager';
|
||||
} else if (product === 'Firefox') {
|
||||
params.component = 'Extension Compatibility';
|
||||
}
|
||||
const apiURL = `${baseAPIURL}?${queryString.stringify(params)}`;
|
||||
const webParams = { ...params };
|
||||
delete webParams.count_only;
|
||||
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
|
||||
return serverSWR(apiURL, async () => {
|
||||
const res = await fetch(apiURL, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
return { count: json.bug_count, url: webURL };
|
||||
});
|
||||
}
|
||||
|
||||
function getBugzillaIssueCounts() {
|
||||
const requests = [];
|
||||
const combinedData = {};
|
||||
|
||||
for (const product of products) {
|
||||
combinedData[product] = {};
|
||||
|
||||
for (const priority of priorities) {
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
priority,
|
||||
bug_severity: null,
|
||||
}).then((result) => {
|
||||
let priorityLabel;
|
||||
switch (priority) {
|
||||
case '--':
|
||||
priorityLabel = 'default';
|
||||
break;
|
||||
default:
|
||||
priorityLabel = priority.toLowerCase();
|
||||
}
|
||||
combinedData[product][`priority-${priorityLabel}`] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const bug_severity of severities) {
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
bug_severity,
|
||||
priority: null,
|
||||
}).then((result) => {
|
||||
let severityLabel;
|
||||
switch (bug_severity) {
|
||||
case 'N/A':
|
||||
severityLabel = 'not-applicable';
|
||||
break;
|
||||
case '--':
|
||||
severityLabel = 'default';
|
||||
break;
|
||||
default:
|
||||
severityLabel = bug_severity.toLowerCase();
|
||||
}
|
||||
combinedData[product][`severity-${severityLabel}`] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
fetchIssueCount({
|
||||
product,
|
||||
bug_severity: null,
|
||||
priority: null,
|
||||
}).then((result) => {
|
||||
combinedData[product][`total`] = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests).then(() => {
|
||||
return combinedData;
|
||||
});
|
||||
}
|
||||
|
||||
function fetchNeedInfo(email, nick) {
|
||||
const encodedEmail = encodeURIComponent(email);
|
||||
let apiURL = `${needsInfoURL}${encodedEmail}`;
|
||||
if (nick === 'UX') {
|
||||
// Limit UX need-infos to web-ext components and products.
|
||||
apiURL = `${apiURL}${webExtOnlyQualifier}`;
|
||||
}
|
||||
return serverSWR(apiURL, async () => {
|
||||
const result = await fetch(apiURL, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return result.json();
|
||||
});
|
||||
}
|
||||
|
||||
function getBugzillaNeedInfos() {
|
||||
const requests = [];
|
||||
const combinedData = {};
|
||||
const BZ_USERS = JSON.parse(process.env.BZ_USERS) || {};
|
||||
|
||||
for (const nick in BZ_USERS) {
|
||||
combinedData[nick] = {};
|
||||
requests.push(
|
||||
fetchNeedInfo(BZ_USERS[nick], nick).then((result) => {
|
||||
combinedData[nick].count = result.bugs.length;
|
||||
if (result.bugs.length) {
|
||||
combinedData[nick].url = `${baseWEBURL}?bug_id=${result.bugs
|
||||
.map((item) => item.id)
|
||||
.join(',')}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests).then(() => {
|
||||
return combinedData;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBugzillaIssueCounts,
|
||||
getBugzillaNeedInfos,
|
||||
};
|
|
@ -1,116 +0,0 @@
|
|||
// Magically require any env variables defined in a local .env file.
|
||||
require('dotenv').config();
|
||||
// Polyfill fetch.
|
||||
require('isomorphic-fetch');
|
||||
const ApolloClient = require('apollo-client').ApolloClient;
|
||||
const createHttpLink = require('apollo-link-http').createHttpLink;
|
||||
const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache;
|
||||
const IntrospectionFragmentMatcher = require('apollo-cache-inmemory')
|
||||
.IntrospectionFragmentMatcher;
|
||||
const getProjectsQuery = require('./queries/getProjects').projects;
|
||||
const getTeamQuery = require('./queries/getTeam').team;
|
||||
const getIssueCountQuery = require('./queries/getIssueCounts').issueCounts;
|
||||
const getGoodFirstBugsQuery = require('./queries/getGoodFirstBugs')
|
||||
.goodFirstBugs;
|
||||
const getMaybeGoodFirstBugsQuery = require('./queries/getMaybeGoodFirstBugs')
|
||||
.maybeGoodFirstBugs;
|
||||
const getMilestoneIssuesQuery = require('./queries/getMilestoneIssues')
|
||||
.milestoneIssues;
|
||||
const getContribWelcomeQuery = require('./queries/getContribWelcome')
|
||||
.contribWelcome;
|
||||
|
||||
const introspectionQueryResultData = require('./fragmentTypes.json');
|
||||
|
||||
function createClient() {
|
||||
const headers = {};
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (process.env.GH_TOKEN) {
|
||||
headers.Authorization = `token ${process.env.GH_TOKEN}`;
|
||||
} else {
|
||||
throw new Error('No GH_TOKEN found');
|
||||
}
|
||||
}
|
||||
|
||||
const fragmentMatcher = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData,
|
||||
});
|
||||
|
||||
// For fetches to work correctly we use a new client instance for
|
||||
// each request to avoid stale data.
|
||||
const client = new ApolloClient({
|
||||
link: createHttpLink({
|
||||
uri: 'https://api.github.com/graphql',
|
||||
headers,
|
||||
}),
|
||||
cache: new InMemoryCache({ fragmentMatcher }),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
async function getProjects(variables) {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getProjectsQuery,
|
||||
variables,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getMilestoneIssues(variables) {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getMilestoneIssuesQuery,
|
||||
variables,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getTeam() {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getTeamQuery,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getGithubIssueCounts() {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getIssueCountQuery,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getGoodFirstBugs() {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getGoodFirstBugsQuery,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getMaybeGoodFirstBugs() {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getMaybeGoodFirstBugsQuery,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getContribWelcome() {
|
||||
const client = createClient();
|
||||
const data = await client.query({
|
||||
query: getContribWelcomeQuery,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProjects,
|
||||
getTeam,
|
||||
getGithubIssueCounts,
|
||||
getGoodFirstBugs,
|
||||
getMaybeGoodFirstBugs,
|
||||
getMilestoneIssues,
|
||||
getContribWelcome,
|
||||
};
|
|
@ -1,126 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
const ghapi = require('./ghapi');
|
||||
const bzapi = require('./bzapi');
|
||||
|
||||
const constants = require('../const');
|
||||
|
||||
const getProjects = async (req, res, next) => {
|
||||
const { year, quarter } = req.query;
|
||||
if (!constants.validYearRX.test(year)) {
|
||||
res.status(400).json({ error: 'Incorrect year format' });
|
||||
} else if (!constants.validQuarterRX.test(quarter)) {
|
||||
res.status(400).json({ error: 'Incorrect quarter format' });
|
||||
} else {
|
||||
const projects = await ghapi.getProjects({
|
||||
projectSearch: `Add-ons ${quarter} ${year}`,
|
||||
});
|
||||
res.json(projects);
|
||||
}
|
||||
};
|
||||
|
||||
const getMilestoneIssues = async (req, res) => {
|
||||
const { milestone } = req.query;
|
||||
if (!constants.validMilestoneRX.test(milestone)) {
|
||||
res.status(400).json({ error: 'Incorrect milestone format' });
|
||||
} else {
|
||||
const query = `repo:mozilla/addons
|
||||
repo:mozilla/addons-server
|
||||
repo:mozilla/addons-frontend
|
||||
repo:mozilla/addons-linter
|
||||
repo:mozilla/addons-code-manager
|
||||
milestone:${milestone}
|
||||
type:issues`;
|
||||
const milestoneIssues = await ghapi.getMilestoneIssues({
|
||||
query: query,
|
||||
});
|
||||
res.json(milestoneIssues);
|
||||
}
|
||||
};
|
||||
|
||||
const getTeam = async (req, res) => {
|
||||
const team = await ghapi.getTeam();
|
||||
res.json(team);
|
||||
};
|
||||
|
||||
const getGithubIssueCounts = async (req, res) => {
|
||||
const issueCounts = await ghapi.getGithubIssueCounts();
|
||||
res.json(issueCounts);
|
||||
};
|
||||
|
||||
const getBugzillaIssueCounts = async (req, res) => {
|
||||
const issueCounts = await bzapi.getBugzillaIssueCounts();
|
||||
res.json(issueCounts);
|
||||
};
|
||||
|
||||
const getBugzillaNeedInfos = async (req, res) => {
|
||||
const needInfos = await bzapi.getBugzillaNeedInfos();
|
||||
res.json(needInfos);
|
||||
};
|
||||
|
||||
const getGoodFirstBugs = async (req, res) => {
|
||||
const goodFirstBugs = await ghapi.getGoodFirstBugs();
|
||||
res.json(goodFirstBugs);
|
||||
};
|
||||
|
||||
const getMaybeGoodFirstBugs = async (req, res) => {
|
||||
const maybeGoodFirstBugs = await ghapi.getMaybeGoodFirstBugs();
|
||||
res.json(maybeGoodFirstBugs);
|
||||
};
|
||||
|
||||
const getContribWelcomeBugs = async (req, res) => {
|
||||
const contribWelcomeBugs = await ghapi.getContribWelcome();
|
||||
res.json(contribWelcomeBugs);
|
||||
};
|
||||
|
||||
function handleErrors(processRequest) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
await processRequest(req, res, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/projects/', handleErrors(getProjects));
|
||||
app.get('/api/team/', handleErrors(getTeam));
|
||||
app.get('/api/github-issue-counts/', handleErrors(getGithubIssueCounts));
|
||||
app.get('/api/bugzilla-issue-counts/', handleErrors(getBugzillaIssueCounts));
|
||||
app.get('/api/bugzilla-need-infos/', handleErrors(getBugzillaNeedInfos));
|
||||
app.get('/api/good-first-bugs/', handleErrors(getGoodFirstBugs));
|
||||
app.get('/api/maybe-good-first-bugs/', handleErrors(getMaybeGoodFirstBugs));
|
||||
app.get('/api/milestone-issues/', handleErrors(getMilestoneIssues));
|
||||
app.get('/api/contrib-welcome/', handleErrors(getContribWelcomeBugs));
|
||||
|
||||
function startServer() {
|
||||
let portOrSocket = process.env.PORT || 5000;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
portOrSocket = '/tmp/nginx.socket';
|
||||
}
|
||||
|
||||
app.listen(portOrSocket);
|
||||
|
||||
if (process.env.DYNO) {
|
||||
console.log('This is on Heroku..!!');
|
||||
fs.openSync('/tmp/app-initialized', 'w');
|
||||
}
|
||||
console.log(`Addons-PM Server listening on ${portOrSocket}`);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && !module.parent) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProjects,
|
||||
getTeam,
|
||||
getGithubIssueCounts,
|
||||
getBugzillaIssueCounts,
|
||||
getBugzillaNeedInfos,
|
||||
getMilestoneIssues,
|
||||
handleErrors,
|
||||
startServer,
|
||||
};
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче