Restructure a bit and sidebar

This commit is contained in:
Nicholas Zuber 2018-11-05 19:23:12 -05:00
parent dd9462afbc
commit 733d0f96a6
8 changed files with 343 additions and 245 deletions

View File

@ -41,7 +41,7 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 9009;
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 9008;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {

View File

@ -11,6 +11,7 @@ import bookmarkAltWhite from './svg/bookmark-alt-white.svg';
import bookmark from './svg/bookmark.svg';
import bookmarks from './svg/bookmarks.svg';
import check from './svg/check.svg';
import clock from './svg/clock.svg';
import convo from './svg/convo.svg';
import doneAll from './svg/done-all.svg';
import done from './svg/done.svg';
@ -70,6 +71,7 @@ Icon.BookmarkAltWhite = createIcon(bookmarkAltWhite);
Icon.Bookmark = createIcon(bookmark);
Icon.Bookmarks = createIcon(bookmarks);
Icon.Check = createIcon(check);
Icon.Clock = createIcon(clock);
Icon.Convo = createIcon(convo);
Icon.DoneAll = createIcon(doneAll);
Icon.Done = createIcon(done);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><path d="M0 0h24v24H0z" fill="none"/><path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@ -1,7 +1,7 @@
import React from 'react';
const CLIENT_ID = '9478c90e57ef3d546ef0';
const REDIRECT_URI = 'http://localhost:9009/login';
const REDIRECT_URI = 'http://localhost:9008/login';
const SCOPES = 'notifications';
const AuthenticationButton = props => (

View File

@ -79,10 +79,8 @@ const GeneralOptionsContainer = styled(NavigationContainer)({
zIndex: '1',
height: 'initial',
minHeight: 60,
width: '100%',
width: '95%',
margin: 0,
marginLeft: 230,
maxWidth: 1000,
background: '#fff',
padding: '8px 16px',
paddingTop: 18,
@ -94,8 +92,9 @@ const GeneralOptionsContainer = styled(NavigationContainer)({
});
const Sidebar = styled('div')({
flex: '0 0 200px',
padding: '0 20px 20px',
flex: '0 0 300px',
padding: '32px 20px',
paddingRight: 0,
display: 'flex',
justifyContent: 'center',
});
@ -110,6 +109,7 @@ const SidebarLink = styled('a')({}, ({active, color}) => ({
alignItems: 'center',
padding: '0 14px',
height: 40,
width: 200,
fontSize: '12px',
fontWeight: 600,
letterSpacing: 0.5,
@ -340,7 +340,14 @@ const Repository = styled('span')({
const PRIssue = styled(Repository)({
fontWeight: 400,
});
}, ({after}) => ({
':after': {
content: `"#${after}"`,
fontSize: 13,
opacity: .3,
marginLeft: 5
}
}));
const Table = styled('table')({
width: '100%',
@ -386,6 +393,7 @@ export default function Scene ({
query,
activeStatus,
allNotificationsCount,
stagedTodayCount,
onChangePage,
onSetActiveStatus,
onClearQuery,
@ -418,10 +426,9 @@ export default function Scene ({
<div style={{marginTop: 60}}>
<NavigationContainer>
<div style={{
maxWidth: 1200,
textAlign: 'right',
margin: '0 auto',
padding: '0 20px 0 40px',
width: '92%'
}}>
<Logo
size={36}
@ -465,221 +472,265 @@ export default function Scene ({
</div>
</div>
</NavigationContainer>
<GeneralOptionsContainer>
<Tab disabled={isLoading}>
<Icon.Refresh
opacity={0.9}
onClick={!isLoading ? (() => onFetchNotifications()) : undefined}
/>
</Tab>
<Tab disabled={isLoading}>
<Icon.Trash
opacity={0.9}
onClick={!isLoading ? (() => onClearCache()) : undefined}
/>
</Tab>
{query ? (
<React.Fragment>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}
>
Showing results for '{query}'
</a>
</div>
<div style={{
display: 'flex',
flexDirection: 'row'
}}>
<div style={{
flex: '0 0 300px'
}}>
<Sidebar>
<FixedContainer>
<div style={{
width: 220,
padding: '0 14px',
margin: '0 11px 12px',
}}>
<h3 style={{
margin: 0
}}>
<Icon.Clock style={{
display: 'inline-block',
verticalAlign: 'middle',
marginRight: '5px',
top: '-3px',
}} />
{moment().format('h:mma')}
</h3>
<span style={{
display: 'block',
padding: '6px 0px',
fontSize: 15,
opacity: 0.7,
}}>{moment().format('dddd, MMMM Do')}</span>
<span style={{
display: 'block',
padding: '6px 0 12px',
fontSize: 12,
opacity: 0.5,
}}>You've triaged {stagedTodayCount} notifications today</span>
</div>
<SidebarLink
active={activeFilter === Filters.ALL}
color="#6772e5"
onClick={() => onSetActiveFilter(Filters.ALL)}>
{activeFilter === Filters.ALL ? (
<Icon.InboxWhite shrink={.6} />
) : (
<Icon.Inbox shrink={.6} />
)}
all notifications
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.PARTICIPATING}
color="#00d19a"
onClick={() => onSetActiveFilter(Filters.PARTICIPATING)}>
{activeFilter === Filters.PARTICIPATING ? (
<Icon.PeopleWhite shrink={.6} />
) : (
<Icon.People shrink={.6} />
)}
{/* participating */}
your triage
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.COMMENT}
color="#00A0F5"
onClick={() => onSetActiveFilter(Filters.COMMENT)}>
{activeFilter === Filters.COMMENT ? (
<Icon.BookmarkAltWhite shrink={.6} />
) : (
<Icon.BookmarkAlt shrink={.6} />
)}
commented
</SidebarLink>
{/* <p>3 triaged in robin-dashboard</p> */}
</FixedContainer>
</Sidebar>
</div>
<div style={{
flex: 1
}}>
<GeneralOptionsContainer>
<Tab disabled={isLoading}>
<Icon.X
<Icon.Refresh
opacity={0.9}
onClick={!isLoading ? (() => onClearQuery()) : undefined}
onClick={!isLoading ? (() => onFetchNotifications()) : undefined}
/>
</Tab>
</React.Fragment>
) : null}
<div style={{float: 'right'}}>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}>
{first}-{last} of about {allNotificationsCount}
</a>
</div>
<Tab disabled={isLoading || isFirstPage}>
<Icon.Prev
opacity={0.9}
onClick={!isLoading && !isFirstPage ? (() => onChangePage(page - 1)) : undefined}
/>
</Tab>
<Tab disabled={isLoading || isLastPage}>
<Icon.Next
opacity={0.9}
onClick={!isLoading && !isLastPage ? (() => onChangePage(page + 1)) : undefined}
/>
</Tab>
</div>
</GeneralOptionsContainer>
<GeneralOptionsContainer style={{paddingTop: 4}}>
<NavTab
color="#00d19a"
active={activeStatus === Status.QUEUED}
onClick={() => onSetActiveStatus(Status.QUEUED)}
href="#">
Queued
</NavTab>
<NavTab
color="#009ef8"
active={activeStatus === Status.STAGED}
onClick={() => onSetActiveStatus(Status.STAGED)}
href="#">
Staged
</NavTab>
<NavTab
color="#f12c3f"
active={activeStatus === Status.CLOSED}
onClick={() => onSetActiveStatus(Status.CLOSED)}
href="#">
Closed
</NavTab>
</GeneralOptionsContainer>
<NotificationsContainer>
<Sidebar>
<FixedContainer>
<SidebarLink
active={activeFilter === Filters.ALL}
color="#6772e5"
onClick={() => onSetActiveFilter(Filters.ALL)}>
{activeFilter === Filters.ALL ? (
<Icon.InboxWhite shrink={.6} />
) : (
<Icon.Inbox shrink={.6} />
)}
all notifications
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.PARTICIPATING}
<Tab disabled={isLoading}>
<Icon.Trash
opacity={0.9}
onClick={!isLoading ? (() => onClearCache()) : undefined}
/>
</Tab>
{query ? (
<React.Fragment>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}
>
Showing results for '{query}'
</a>
</div>
<Tab disabled={isLoading}>
<Icon.X
opacity={0.9}
onClick={!isLoading ? (() => onClearQuery()) : undefined}
/>
</Tab>
</React.Fragment>
) : null}
<div style={{float: 'right'}}>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}>
{first}-{last} of about {allNotificationsCount}
</a>
</div>
<Tab disabled={isLoading || isFirstPage}>
<Icon.Prev
opacity={0.9}
onClick={!isLoading && !isFirstPage ? (() => onChangePage(page - 1)) : undefined}
/>
</Tab>
<Tab disabled={isLoading || isLastPage}>
<Icon.Next
opacity={0.9}
onClick={!isLoading && !isLastPage ? (() => onChangePage(page + 1)) : undefined}
/>
</Tab>
</div>
</GeneralOptionsContainer>
<GeneralOptionsContainer style={{paddingTop: 4}}>
<NavTab
color="#00d19a"
onClick={() => onSetActiveFilter(Filters.PARTICIPATING)}>
{activeFilter === Filters.PARTICIPATING ? (
<Icon.PeopleWhite shrink={.6} />
) : (
<Icon.People shrink={.6} />
)}
participating
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.COMMENT}
color="#00A0F5"
onClick={() => onSetActiveFilter(Filters.COMMENT)}>
{activeFilter === Filters.COMMENT ? (
<Icon.BookmarkAltWhite shrink={.6} />
) : (
<Icon.BookmarkAlt shrink={.6} />
)}
commented
</SidebarLink>
</FixedContainer>
</Sidebar>
<Notifications>
{isFetchingNotifications ? (
<LoaderContainer>
<LoadingIcon />
</LoaderContainer>
) : notifications.length <= 0 ? (
<Message>
<p style={{
fontSize: 16,
fontWeight: 400,
}}>
No {activeStatus.toLowerCase()} notifications</p>
<p style={{
fontSize: 12,
fontWeight: 400,
color: '#5f6368'
}}>
<span role="img" aria-label="hooray">🎉</span> You're all set here for the moment</p>
</Message>
) : (
<Table>
<tbody>
{notifications.map(n => (
<NotificationRow key={n.id}>
<TableItem>
<div style={{ float: 'left', marginTop: 2 }}>
{getPRIssueIcon(n.type, n.reasons)}
</div>
</TableItem>
<TableItem style={{height: 36, cursor: 'pointer', userSelect: 'none'}} width={400} onClick={() => {
window.open(n.url);
onStageThread(n.id)
}}>
<NotificationTitle>
<PRIssue>{n.name}</PRIssue>
</NotificationTitle>
<Timestamp>{getRelativeTime(n.updated_at)}</Timestamp>
</TableItem>
<TableItem width={100}>
<InlineBlockContainer>
{n.badges.map(badge => {
switch (badge) {
case Badges.HOT:
// lots of `reasons` within short time frame
return <Icon.Hot shrink={0.75} />
case Badges.OLD:
// old
return <Icon.Alarm shrink={0.75} />
case Badges.COMMENTS:
// lots of `reasons`
return <Icon.Convo shrink={0.75} />
default:
return null;
}
})}
</InlineBlockContainer>
</TableItem>
<TableItem width={250}>
<Repository
onClick={() => window.open(n.repositoryUrl)}
style={{cursor: 'pointer', userSelect: 'none'}}>
{n.repository}</Repository>
</TableItem>
<TableItem width={150} style={{textAlign: 'right'}}>
<NotificationTab>
{n.score}
</NotificationTab>
<NotificationTab>
<Icon.Check
opacity={0.9}
onClick={!isLoading ? (() => onStageThread(n.id)) : undefined}
/>
</NotificationTab>
<NotificationTab>
<Icon.X
opacity={0.9}
onClick={!isLoading ? (() => onMarkAsRead(n.id)) : undefined}
/>
</NotificationTab>
</TableItem>
</NotificationRow>
))}
</tbody>
</Table>
)}
</Notifications>
</NotificationsContainer>
active={activeStatus === Status.QUEUED}
onClick={() => onSetActiveStatus(Status.QUEUED)}
href="#">
Queued
</NavTab>
<NavTab
color="#009ef8"
active={activeStatus === Status.STAGED}
onClick={() => onSetActiveStatus(Status.STAGED)}
href="#">
Staged
</NavTab>
<NavTab
color="#f12c3f"
active={activeStatus === Status.CLOSED}
onClick={() => onSetActiveStatus(Status.CLOSED)}
href="#">
Closed
</NavTab>
</GeneralOptionsContainer>
<NotificationsContainer>
<Notifications>
{isFetchingNotifications ? (
<LoaderContainer>
<LoadingIcon />
</LoaderContainer>
) : notifications.length <= 0 ? (
<Message>
<p style={{
fontSize: 16,
fontWeight: 400,
}}>
No {activeStatus.toLowerCase()} notifications</p>
<p style={{
fontSize: 12,
fontWeight: 400,
color: '#5f6368'
}}>
<span role="img" aria-label="hooray">🎉</span> You're all set here for the moment</p>
</Message>
) : (
<Table>
<tbody>
{notifications.map(n => (
<NotificationRow key={n.id}>
<TableItem>
<div style={{ float: 'left', marginTop: 2 }}>
{getPRIssueIcon(n.type, n.reasons)}
</div>
</TableItem>
<TableItem style={{height: 36, cursor: 'pointer', userSelect: 'none'}} width={400} onClick={() => {
window.open(n.url);
onStageThread(n.id)
}}>
<NotificationTitle>
<PRIssue after={n.number}>{n.name}</PRIssue>
</NotificationTitle>
<Timestamp>{getRelativeTime(n.updated_at)}</Timestamp>
</TableItem>
<TableItem width={100}>
<InlineBlockContainer>
{n.badges.map(badge => {
switch (badge) {
case Badges.HOT:
// lots of `reasons` within short time frame
return <Icon.Hot shrink={0.75} />
case Badges.OLD:
// old
return <Icon.Alarm shrink={0.75} />
case Badges.COMMENTS:
// lots of `reasons`
return <Icon.Convo shrink={0.75} />
default:
return null;
}
})}
</InlineBlockContainer>
</TableItem>
<TableItem width={250}>
<Repository
onClick={() => window.open(n.repositoryUrl)}
style={{cursor: 'pointer', userSelect: 'none'}}>
{n.repository}</Repository>
</TableItem>
<TableItem width={150} style={{textAlign: 'right'}}>
<NotificationTab>
{n.score}
</NotificationTab>
<NotificationTab>
<Icon.Check
opacity={0.9}
onClick={!isLoading ? (() => onStageThread(n.id, n.repository)) : undefined}
/>
</NotificationTab>
<NotificationTab>
<Icon.X
opacity={0.9}
onClick={!isLoading ? (() => onMarkAsRead(n.id)) : undefined}
/>
</NotificationTab>
</TableItem>
</NotificationRow>
))}
</tbody>
</Table>
)}
</Notifications>
</NotificationsContainer>
</div>
</div>
</div>
);
}

View File

@ -88,7 +88,7 @@ function badgesOf (notification) {
// If you've been tagged in for review and the most recent update happened over
// 4 hours ago, that specific time is subject to change.
if (notification.reasons.some(r => r.reason === Reasons.REVIEW_REQUESTED) &&
moment().diff(moment(notification.reasons.pop().time).hours, 'hours') > 4) {
moment().diff(moment(notification.reasons[notification.reasons.length - 1].time).hours, 'hours') > 4) {
badges.push(Badges.OLD);
}
return badges;
@ -169,6 +169,13 @@ class NotificationsPage extends React.Component {
}, 500);
}
enhancedOnStageThread = (thread_id, repository) => {
console.warn('staging thread', thread_id, 'in repo', repository);
this.props.storageApi.incrStat('stagedCount');
this.props.storageApi.incrStat(repository + '-stagedCount');
this.props.notificationsApi.stageThread(thread_id);
}
render () {
if (!this.props.authApi.token) {
return <Redirect noThrow to={routes.LOGIN} />
@ -176,7 +183,6 @@ class NotificationsPage extends React.Component {
const {
fetchNotifications,
stageThread,
markAsRead,
clearCache,
notifications,
@ -207,7 +213,6 @@ class NotificationsPage extends React.Component {
}
const filteredNotifications = notifications.filter(filterMethod);
const allNotificationsCount = filteredNotifications.length;
const notificationsQueued = filteredNotifications.filter(n => n.status === Status.QUEUED);
const notificationsStaged = filteredNotifications.filter(n => n.status === Status.STAGED);
@ -246,12 +251,15 @@ class NotificationsPage extends React.Component {
lastNumbered = 0;
}
const stagedTodayCount = this.props.storageApi.getStat('stagedCount')[0];
return (
<Scene
stagedTodayCount={stagedTodayCount || 0}
first={firstNumbered}
last={lastNumbered}
lastPage={lastPage}
allNotificationsCount={allNotificationsCount}
allNotificationsCount={scoredAndSortedNotifications.length}
notifications={notificationsOnPage}
query={this.state.query}
page={this.state.currentPage}
@ -265,7 +273,7 @@ class NotificationsPage extends React.Component {
onFetchNotifications={fetchNotifications}
onMarkAsRead={markAsRead}
onClearCache={clearCache}
onStageThread={stageThread}
onStageThread={this.enhancedOnStageThread}
onRefreshNotifications={this.props.storageApi.refreshNotifications}
isSearching={this.state.isSearching}
isFetchingNotifications={isFetchingNotifications}

View File

@ -1,7 +1,6 @@
import React from 'react';
import {AuthConsumer} from './Auth';
import {StorageProvider} from './Storage';
import {MockNotifications} from '../utils/mocks';
import {Status} from '../constants/status';
const BASE_GITHUB_API_URL = 'https://api.github.com';
@ -107,16 +106,6 @@ class NotificationsProvider extends React.Component {
});
}
// @TODO remove this mock when ready
mockRequestPage = page => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve({
headers: {},
json: MockNotifications
}), 1000)
});
}
requestFetchNotifications = (page = 1, optimizePolling = true) => {
return this.requestPage(page, optimizePolling)
.then(({headers, json}) => {

View File

@ -4,6 +4,7 @@ import {Status} from '../constants/status';
import {Reasons} from '../constants/reasons';
const LOCAL_STORAGE_PREFIX = '__meteorite_noti_cache__';
const LOCAL_STORAGE_STATISTIC_PREFIX = '__meteorite_statistic_cache__';
const getMockReasons = n => {
const reasons = Object.values(Reasons);
@ -58,11 +59,63 @@ class StorageProvider extends React.Component {
// this.setState({ notifications: mockNotifications });
}
/**
* Stats are broken up since they are fetched and set often, we want to avoid
* the JSON parsing overhead.
*
* Our statistics are indexed by the date (current unique day) we had recorded it.
* This _does_ limit us by making this choice - the most granular we can get with
* statistics is by day. This is an intentional design decision and we could always
* change it later at some point if we really wanted to.
*
* Statistics take the form __DATE-NAME. For example:
* ```
* __meteorite_noti_cache__2018-11-05-robin-extension-staged -> 52
* __meteorite_noti_cache__2018-11-06-robin-dashboard-staged -> 4
* ```
*/
getStat = (stat, startTime = moment(), endTime = moment().add(1, 'day')) => {
const response = [];
// Range reflects `[start, end)`
for (let m = startTime.clone(); m.isBefore(endTime); m.add(1, 'day')) {
const key = m.format('YYYY-MM-DD');
const value = window.localStorage.getItem(`${LOCAL_STORAGE_STATISTIC_PREFIX}${key}-${stat}`);
if (value !== null) {
response.push(value);
}
}
return response;
}
/**
* Since our stats right now are just numbers, we can assume "setting" will always
* increment. This is a pretty bold assumption that makes things simpler for now,
* so we're going to go with it for the time being.
*/
incrStat = (stat, time = moment()) => {
const key = time.format('YYYY-MM-DD');
const oldValue = window.localStorage.getItem(`${LOCAL_STORAGE_STATISTIC_PREFIX}${key}-${stat}`);
if (oldValue !== null) {
window.localStorage.setItem(`${LOCAL_STORAGE_STATISTIC_PREFIX}${key}-${stat}`, parseInt(oldValue, 10) + 1);
} else {
window.localStorage.setItem(`${LOCAL_STORAGE_STATISTIC_PREFIX}${key}-${stat}`, 1);
}
}
// val value : Object
setItem = (id, value) => {
window.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${id}`, JSON.stringify(value));
}
getItem = id => {
try {
return JSON.parse(window.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`));
} catch (e) {
return null;
}
}
removeItem = id => {
// We never really want to purge anything from the cache if we can help it,
// since there's always a chance that a read notification can be resurrected.
@ -77,14 +130,6 @@ class StorageProvider extends React.Component {
this.setItem(id, closed_cached_n);
}
getItem = id => {
try {
return JSON.parse(window.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`));
} catch (e) {
return null;
}
}
clearCache = () => {
window.localStorage.clear();
}
@ -96,7 +141,9 @@ class StorageProvider extends React.Component {
getItem: this.getItem,
removeItem: this.removeItem,
clearCache: this.clearCache,
refreshNotifications: this.refreshNotifications
refreshNotifications: this.refreshNotifications,
getStat: this.getStat,
incrStat: this.incrStat,
});
}
}