1
0
Fork 0

Running out of time!

master
Ambrose Chua 2017-04-16 17:03:00 +08:00
parent 82ffd339bc
commit 952123f0a4
13 changed files with 1255 additions and 52 deletions

View File

@ -17,19 +17,22 @@ A school event planner and timetable
- [x] fake validator for jwt at protected endpoints
- [ ] assume user is admin
- [x] Create group
- [ ] Create one-off events
- [x] Create one-off events
- [ ] Create attachments
- [ ] Description
- [ ] File
- [ ] Create group CCAs
- [ ] Create CCA schedules
- [ ] Create group mentor
- [x] Create group CCAs
- [x] Create CCA schedules
- [ ] Differentiate CCAs from Mentor Groups
- [x] Create group mentor
- [ ] Importable timetables
- [ ] Display events as agenda
- [ ] Display events as calendar
- [x] Display events as agenda
- [x] Display events as calendar
- [ ] Create sample data
- [ ] Refactor toolbar mutator for homepage pagination
## Security Pitfalls
- Auth mechanism not verified

571
app/calendar.css Normal file
View File

@ -0,0 +1,571 @@
:global .calendar {
height: calc(100vh - 112px - 17px * 2);
}
:global .rbc-btn {
color: inherit;
font: inherit;
margin: 0;
}
:global button.rbc-btn {
overflow: visible;
text-transform: none;
-webkit-appearance: button;
cursor: pointer;
}
:global button[disabled].rbc-btn {
cursor: not-allowed;
}
:global button.rbc-input::-moz-focus-inner {
border: 0;
padding: 0;
}
:global .rbc-calendar {
box-sizing: border-box;
height: 100%;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex-align: stretch;
align-items: stretch;
}
:global .rbc-calendar *,
:global .rbc-calendar *:before,
:global .rbc-calendar *:after {
box-sizing: inherit;
}
:global .rbc-abs-full,
:global .rbc-row-bg {
overflow: hidden;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
:global .rbc-ellipsis,
:global .rbc-event-label,
:global .rbc-row-segment .rbc-event-content,
:global .rbc-show-more {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global .rbc-rtl {
direction: rtl;
}
:global .rbc-off-range {
color: #b3b3b3;
}
:global .rbc-header {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 3px;
text-align: center;
vertical-align: middle;
font-weight: bold;
font-size: 90%;
min-height: 0;
}
:global .rbc-header > a,
:global .rbc-header > a:active,
:global .rbc-header > a:visited {
color: inherit;
text-decoration: none;
}
:global .rbc-row-content {
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
z-index: 4;
}
:global .rbc-today {
background-color: #eaf6ff;
}
:global .rbc-toolbar {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
margin-bottom: 10px;
font-size: 16px;
}
:global .rbc-toolbar .rbc-toolbar-label {
width: 100%;
padding: 0 10px;
text-align: center;
}
:global .rbc-toolbar button {
color: #373a3c;
display: inline-block;
margin: 0;
text-align: center;
vertical-align: middle;
background: none;
background-image: none;
border: 1px solid #ccc;
padding: .375rem 1rem;
border-radius: 4px;
line-height: normal;
white-space: nowrap;
}
:global .rbc-toolbar button:active,
:global .rbc-toolbar button.rbc-active {
background-image: none;
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
background-color: #e6e6e6;
border-color: #adadad;
}
:global .rbc-toolbar button:active:hover,
:global .rbc-toolbar button.rbc-active:hover,
:global .rbc-toolbar button:active:focus,
:global .rbc-toolbar button.rbc-active:focus {
color: #373a3c;
background-color: #d4d4d4;
border-color: #8c8c8c;
}
:global .rbc-toolbar button:focus {
color: #373a3c;
background-color: #e6e6e6;
border-color: #adadad;
}
:global .rbc-toolbar button:hover {
color: #373a3c;
background-color: #e6e6e6;
border-color: #adadad;
}
:global .rbc-btn-group {
display: inline-block;
white-space: nowrap;
}
:global .rbc-btn-group > button:first-child:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
:global .rbc-btn-group > button:last-child:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
:global .rbc-rtl .rbc-btn-group > button:first-child:not(:last-child) {
border-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
:global .rbc-rtl .rbc-btn-group > button:last-child:not(:first-child) {
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
:global .rbc-btn-group > button:not(:first-child):not(:last-child) {
border-radius: 0;
}
:global .rbc-btn-group button + button {
margin-left: -1px;
}
:global .rbc-rtl .rbc-btn-group button + button {
margin-left: 0;
margin-right: -1px;
}
:global .rbc-btn-group + .rbc-btn-group,
:global .rbc-btn-group + button {
margin-left: 10px;
}
:global .rbc-event {
cursor: pointer;
padding: 2px 5px;
background-color: #3174ad;
border-radius: 5px;
color: #fff;
}
:global .rbc-event.rbc-selected {
background-color: #265985;
}
:global .rbc-event-label {
font-size: 80%;
}
:global .rbc-event-overlaps {
box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5);
}
:global .rbc-event-continues-prior {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
:global .rbc-event-continues-after {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
:global .rbc-event-continues-earlier {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:global .rbc-event-continues-later {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
:global .rbc-row {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: row;
flex-direction: row;
}
:global .rbc-row-segment {
padding: 0 1px 1px 1px;
}
:global .rbc-selected-cell {
background-color: rgba(0, 0, 0, 0.1);
}
:global .rbc-show-more {
background-color: rgba(255, 255, 255, 0.3);
z-index: 4;
font-weight: bold;
font-size: 85%;
height: auto;
line-height: normal;
white-space: nowrap;
}
:global .rbc-month-view {
position: relative;
border: 1px solid #DDD;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex: 1 0 0px;
flex: 1 0 0;
width: 100%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
height: 100%;
}
:global .rbc-month-view .rbc-header {
border-bottom: 1px solid #DDD;
}
:global .rbc-month-view .rbc-header + .rbc-header {
border-left: 1px solid #DDD;
}
:global .rbc-rtl .rbc-month-view .rbc-header + .rbc-header {
border-left-width: 0;
border-right: 1px solid #DDD;
}
:global .rbc-month-header {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: row;
flex-direction: row;
}
:global .rbc-month-row {
display: -ms-flexbox;
display: flex;
position: relative;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex: 1 0 0px;
flex: 1 0 0;
overflow: hidden;
height: 100%;
}
:global .rbc-month-row + .rbc-month-row {
border-top: 1px solid #DDD;
}
:global .rbc-date-cell {
padding-right: 5px;
text-align: right;
}
:global .rbc-date-cell.rbc-now {
font-weight: bold;
}
:global .rbc-date-cell > a,
:global .rbc-date-cell > a:active,
:global .rbc-date-cell > a:visited {
color: inherit;
text-decoration: none;
}
:global .rbc-row-bg {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: row;
flex-direction: row;
-ms-flex: 1 0 0px;
flex: 1 0 0;
overflow: hidden;
}
:global .rbc-day-bg + .rbc-day-bg {
border-left: 1px solid #DDD;
}
:global .rbc-rtl .rbc-day-bg + .rbc-day-bg {
border-left-width: 0;
border-right: 1px solid #DDD;
}
:global .rbc-overlay {
position: absolute;
z-index: 5;
border: 1px solid #e5e5e5;
background-color: #fff;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
padding: 10px;
}
:global .rbc-overlay > * + * {
margin-top: 1px;
}
:global .rbc-overlay-header {
border-bottom: 1px solid #e5e5e5;
margin: -10px -10px 5px -10px;
padding: 2px 10px;
}
:global .rbc-agenda-view {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex: 1 0 0px;
flex: 1 0 0;
overflow: auto;
}
:global .rbc-agenda-view table {
width: 100%;
border: 1px solid #DDD;
}
:global .rbc-agenda-view table tbody > tr > td {
padding: 5px 10px;
vertical-align: top;
}
:global .rbc-agenda-view table .rbc-agenda-time-cell {
padding-left: 15px;
padding-right: 15px;
text-transform: lowercase;
}
:global .rbc-agenda-view table tbody > tr > td + td {
border-left: 1px solid #DDD;
}
:global .rbc-rtl .rbc-agenda-view table tbody > tr > td + td {
border-left-width: 0;
border-right: 1px solid #DDD;
}
:global .rbc-agenda-view table tbody > tr + tr {
border-top: 1px solid #DDD;
}
:global .rbc-agenda-view table thead > tr > th {
padding: 3px 5px;
text-align: left;
border-bottom: 1px solid #DDD;
}
:global .rbc-rtl .rbc-agenda-view table thead > tr > th {
text-align: right;
}
:global .rbc-agenda-time-cell {
text-transform: lowercase;
}
:global .rbc-agenda-time-cell .rbc-continues-after:after {
content: ' »';
}
:global .rbc-agenda-time-cell .rbc-continues-prior:before {
content: '« ';
}
:global .rbc-agenda-date-cell,
:global .rbc-agenda-time-cell {
white-space: nowrap;
}
:global .rbc-agenda-event-cell {
width: 100%;
}
:global .rbc-time-column {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
min-height: 100%;
}
:global .rbc-time-column .rbc-timeslot-group {
-ms-flex: 1;
flex: 1;
}
:global .rbc-timeslot-group {
border-bottom: 1px solid #DDD;
min-height: 40px;
display: -ms-flexbox;
display: flex;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
}
:global .rbc-time-gutter,
:global .rbc-header-gutter {
-ms-flex: none;
flex: none;
}
:global .rbc-label {
padding: 0 5px;
}
:global .rbc-day-slot {
position: relative;
}
:global .rbc-day-slot .rbc-event {
border: 1px solid #265985;
display: -ms-flexbox;
display: flex;
max-height: 100%;
-ms-flex-flow: column wrap;
flex-flow: column wrap;
-ms-flex-align: start;
align-items: flex-start;
overflow: hidden;
}
:global .rbc-day-slot .rbc-event-label {
-ms-flex: none;
flex: none;
padding-right: 5px;
width: auto;
}
:global .rbc-day-slot .rbc-event-content {
width: 100%;
-ms-flex: 1 1 0px;
flex: 1 1 0;
word-wrap: break-word;
line-height: 1;
height: 100%;
min-height: 1em;
}
:global .rbc-day-slot .rbc-time-slot {
border-top: 1px solid #f7f7f7;
}
:global .rbc-time-slot {
-ms-flex: 1 0 0px;
flex: 1 0 0;
}
:global .rbc-time-slot.rbc-now {
font-weight: bold;
}
:global .rbc-day-header {
text-align: center;
}
:global .rbc-day-slot .rbc-event {
position: absolute;
z-index: 2;
}
:global .rbc-slot-selection {
z-index: 10;
position: absolute;
cursor: default;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 75%;
padding: 3px;
}
:global .rbc-time-view {
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
-ms-flex: 1;
flex: 1;
width: 100%;
border: 1px solid #DDD;
min-height: 0;
}
:global .rbc-time-view .rbc-time-gutter {
white-space: nowrap;
}
:global .rbc-time-view .rbc-allday-cell {
width: 100%;
position: relative;
}
:global .rbc-time-view .rbc-allday-events {
position: relative;
z-index: 4;
}
:global .rbc-time-view .rbc-row {
min-height: 20px;
}
:global .rbc-time-header {
display: -ms-flexbox;
display: flex;
-ms-flex: 0 0 auto;
flex: 0 0 auto;
-ms-flex-direction: column;
flex-direction: column;
}
:global .rbc-time-header.rbc-overflowing {
border-right: 1px solid #DDD;
}
:global .rbc-rtl .rbc-time-header.rbc-overflowing {
border-right-width: 0;
border-left: 1px solid #DDD;
}
:global .rbc-time-header > .rbc-row > * + * {
border-left: 1px solid #DDD;
}
:global .rbc-rtl .rbc-time-header > .rbc-row > * + * {
border-left-width: 0;
border-right: 1px solid #DDD;
}
:global .rbc-time-header > .rbc-row:first-child {
border-bottom: 1px solid #DDD;
}
:global .rbc-time-header .rbc-gutter-cell {
-ms-flex: none;
flex: none;
}
:global .rbc-time-header > .rbc-gutter-cell + * {
width: 100%;
}
:global .rbc-time-content {
display: -ms-flexbox;
display: flex;
-ms-flex: 1 0 0%;
flex: 1 0 0%;
-ms-flex-align: start;
align-items: flex-start;
width: 100%;
border-top: 2px solid #DDD;
overflow-y: auto;
position: relative;
}
:global .rbc-time-content > .rbc-time-gutter {
-ms-flex: none;
flex: none;
}
:global .rbc-time-content > * + * > * {
border-left: 1px solid #DDD;
}
:global .rbc-rtl .rbc-time-content > * + * > * {
border-left-width: 0;
border-right: 1px solid #DDD;
}
:global .rbc-time-content > .rbc-day-slot {
width: 100%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
:global .rbc-current-time-indicator {
position: absolute;
z-index: 1;
left: 0;
height: 1px;
background-color: #74ad31;
pointer-events: none;
}
:global .rbc-current-time-indicator::before {
display: block;
position: absolute;
left: -3px;
top: -3px;
content: ' ';
background-color: #74ad31;
border-radius: 50%;
width: 8px;
height: 8px;
}
:global .rbc-rtl .rbc-current-time-indicator::before {
left: 0;
right: -3px;
}
/* vim: set expandtab ts=2 sw=2: */

View File

@ -7,7 +7,7 @@ export default class AddEventDialog extends React.Component {
super(props, context);
const now = new Date();
this.state = {
group: null,
group: parseInt(props.group, 10), // TODO: make ids type independent in code
name: '',
start: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8),
end: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 10),
@ -160,6 +160,11 @@ export default class AddEventDialog extends React.Component {
AddEventDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
onDone: React.PropTypes.func.isRequired,
group: React.PropTypes.string,
};
AddEventDialog.defaultProps = {
group: null,
};
AddEventDialog.contextTypes = {

View File

@ -0,0 +1,174 @@
import React from 'react';
import { Dialog, Input, Dropdown, TimePicker } from 'react-toolbox';
export default class AddEventWeeklyDialog extends React.Component {
constructor(props, context) {
super(props, context);
const now = new Date();
this.state = {
group: parseInt(props.group, 10), // TODO: make ids type independent in code
name: '',
day: null,
starttime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8),
endtime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10),
days: [
{ value: 0, label: 'Sunday' },
{ value: 1, label: 'Monday' },
{ value: 2, label: 'Tuesday' },
{ value: 3, label: 'Wednesday' },
{ value: 4, label: 'Thursday' },
{ value: 5, label: 'Friday' },
{ value: 6, label: 'Saturday' },
],
groups: [],
};
this.handleGroupChange = this.handleGroupChange.bind(this);
this.handleNameChange = this.handleNameChange.bind(this);
this.handleDayChange = this.handleDayChange.bind(this);
this.handleStartTimeChange = this.handleStartTimeChange.bind(this);
this.handleEndTimeChange = this.handleEndTimeChange.bind(this);
this.addEvent = this.addEvent.bind(this);
this.actions = [
{ label: 'Cancel', onClick: this.props.onCancel, accent: true },
{ label: 'Add', onClick: this.addEvent, accent: true },
];
this.fetchGroups(context);
}
fetchGroups(context = this.context) {
return fetch(`/api/v1/schools/${context.user.school}/users/${context.user.id}/groups/`, {
headers: {
FakeAuth: true,
FakeID: context.user.id,
},
})
.then(data => data.json())
.then((data) => {
this.setState({
groups: data.map(g => ({ value: g.id, label: g.name })),
});
})
.catch((err) => {
console.error(err);
});
}
addEvent() {
const method = 'POST';
const headers = new Headers();
headers.append('FakeAuth', 'true');
headers.append('FakeID', this.context.user.id);
headers.append('Content-Type', 'application/json');
const body = JSON.stringify({
name: this.state.name,
day: this.state.day,
starttime: this.state.starttime,
endtime: this.state.endtime,
});
fetch(`/api/v1/schools/${this.context.user.school}/groups/${this.state.group}/eventsWeekly/`, {
method, headers, body,
})
.then(() => {
this.props.onDone();
})
.catch((err) => {
console.error(err);
});
}
handleGroupChange(value) {
this.setState({
group: value,
});
}
handleNameChange(value) {
this.setState({
name: value,
});
}
handleDayChange(value) {
this.setState({
day: value,
});
}
handleStartTimeChange(value) {
this.setState({
starttime: value,
});
}
handleEndTimeChange(value) {
this.setState({
endtime: value,
});
}
render() {
return (
<Dialog
{...this.props}
actions={this.actions}
title="Create a weekly event"
>
<Dropdown
auto
label="Group"
source={this.state.groups}
value={this.state.group}
required
onChange={this.handleGroupChange}
/>
<Input
type="text"
label="Name"
value={this.state.name}
required
onChange={this.handleNameChange}
/>
<Dropdown
auto
label="Day"
source={this.state.days}
value={this.state.day}
required
onChange={this.handleDayChange}
/>
<TimePicker
label="Start Time"
value={this.state.starttime}
required
onChange={this.handleStartTimeChange}
/>
<TimePicker
label="End Time"
value={this.state.endtime}
required
onChange={this.handleEndTimeChange}
/>
</Dialog>
);
}
}
AddEventWeeklyDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
onDone: React.PropTypes.func.isRequired,
group: React.PropTypes.string,
};
AddEventWeeklyDialog.defaultProps = {
group: null,
};
AddEventWeeklyDialog.contextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -10,9 +10,20 @@ import PageGroup from '../pages/group';
export default class App extends React.Component {
getChildContext() {
let cb = () => {};
let pN = () => {};
let pP = () => {};
return {
user: {},
token: null,
tooling: {
setToolbar: (o) => { cb(o); },
setPaginatePrev: (f) => { pP = f; },
setPaginateNext: (f) => { pN = f; },
paginatePrev: () => { pP(); },
paginateNext: () => { pN(); },
onChange: (f) => { cb = f; },
},
};
}
@ -35,4 +46,6 @@ App.childContextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
tooling: React.PropTypes.object.isRequired,
};

View File

@ -3,15 +3,37 @@ import React from 'react';
import { Layout, NavDrawer, Panel, AppBar, Navigation, Link, List, ListItem } from 'react-toolbox';
export default class LayoutMain extends React.Component {
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
this.state = {
drawerActive: false,
title: 'Chronos',
showPagination: false,
};
this.context = context;
// eslint-disable-next-line no-param-reassign
this.context.tooling.onChange(this.setToolbar.bind(this));
this.paginatePrev = this.paginatePrev.bind(this);
this.paginateNext = this.paginateNext.bind(this);
this.toggleDrawerActive = this.toggleDrawerActive.bind(this);
}
setToolbar(o) {
this.setState({
title: o.title || 'Chronos',
showPagination: o.showPagination,
});
}
paginatePrev() {
this.context.tooling.onPaginatePrev();
}
paginateNext() {
this.context.tooling.onPaginateNext();
}
toggleDrawerActive() {
this.setState({
drawerActive: !this.state.drawerActive, // TODO: use function instead
@ -73,13 +95,27 @@ export default class LayoutMain extends React.Component {
</NavDrawer>
<Panel>
<AppBar
title="Chronos"
title={this.state.title}
leftIcon="menu"
onLeftIconClick={this.toggleDrawerActive}
>
<Navigation
type="horizontal"
>
{this.state.showPagination &&
<Link
style={{ color: 'var(--color-dark-contrast)' }}
icon="navigate_before"
onClick={this.paginatePrev}
/>
}
{this.state.showPagination &&
<Link
style={{ color: 'var(--color-dark-contrast)' }}
icon="navigate_next"
onClick={this.paginateNext}
/>
}
{this.context.user.email ?
<Link
style={{ color: 'var(--color-dark-contrast)' }}
@ -119,5 +155,7 @@ LayoutMain.contextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
tooling: React.PropTypes.object.isRequired,
};

View File

@ -1,16 +1,28 @@
import React from 'react';
import { List, ListSubHeader, ListItem } from 'react-toolbox';
import { List, ListSubHeader, ListItem, ListDivider, FontIcon } from 'react-toolbox';
import moment from 'moment-timezone';
import AddEventDialog from '../components/addeventdialog';
import AddEventWeeklyDialog from '../components/addeventweeklydialog';
const getDay = d => 'Sunday Monday Tuesday Wednesday Thursday Friday Saturday'.split(' ')[d];
export default class PageGroup extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
id: parseInt(props.match.params.id, 10),
id: props.match.params.id,
group: {},
addGroupDialogActive: false,
addEventDialogActive: false,
addEventWeeklyDialogActive: false,
};
this.showAddEventDialog = this.showAddEventDialog.bind(this);
this.hideAddEventDialog = this.hideAddEventDialog.bind(this);
this.showAddEventWeeklyDialog = this.showAddEventWeeklyDialog.bind(this);
this.hideAddEventWeeklyDialog = this.hideAddEventWeeklyDialog.bind(this);
this.fetchGroup(context); // TODO: split into three backend calls
}
@ -32,6 +44,30 @@ export default class PageGroup extends React.Component {
});
}
showAddEventDialog() {
this.setState({
addEventDialogActive: true,
});
}
hideAddEventDialog() {
this.setState({
addEventDialogActive: false,
});
}
showAddEventWeeklyDialog() {
this.setState({
addEventWeeklyDialogActive: true,
});
}
hideAddEventWeeklyDialog() {
this.setState({
addEventWeeklyDialogActive: false,
});
}
render() {
return (
<main>
@ -47,13 +83,37 @@ export default class PageGroup extends React.Component {
<ListItem
key={e.id}
caption={e.name}
legend={`${moment(e.start).format('dddd, MMMM Do YYYY, HH:mm')} to ${moment(e.end).format('MMMM Do, HH:mm')}`}
rightActions={[
<FontIcon key={0} value="delete" onClick={() => console.log('Hi')} />,
]}
/>
))}
{
// TODO: cca schedule or class timetable
}
</List>
<List>
<ListItem
caption="Create a one-time event"
leftIcon="add"
onClick={this.showAddEventDialog}
/>
<ListDivider />
<ListSubHeader
caption="Weekly Events"
/>
{this.state.group.eventsWeekly && this.state.group.eventsWeekly.map(e => (
<ListItem
key={e.id}
caption={e.name}
legend={`${getDay(e.day)}, ${e.starttime.slice(0, -3)} to ${e.endtime.slice(0, -3)}`}
rightActions={[
<FontIcon key={0} value="delete" onClick={() => console.log('Hi')} />,
]}
/>
))}
<ListItem
caption="Create a weekly event"
leftIcon="add"
onClick={this.showAddEventWeeklyDialog}
/>
<ListDivider />
<ListSubHeader
caption="Members"
/>
@ -64,6 +124,22 @@ export default class PageGroup extends React.Component {
/>
))}
</List>
<AddEventDialog
group={this.state.id}
active={this.state.addEventDialogActive}
onCancel={this.hideAddEventDialog}
onDone={this.fetchGroup}
onEscKeyDown={this.hideAddEventDialog}
onOverlayClick={this.hideAddEventDialog}
/>
<AddEventWeeklyDialog
group={this.state.id}
active={this.state.addEventWeeklyDialogActive}
onCancel={this.hideAddEventWeeklyDialog}
onDone={this.fetchGroup}
onEscKeyDown={this.hideAddEventWeeklyDialog}
onOverlayClick={this.hideAddEventWeeklyDialog}
/>
</main>
);
}

View File

@ -60,6 +60,7 @@ export default class PageGroups extends React.Component {
<ListItem
key={group.id}
caption={group.name}
legend="X upcoming events, X members"
onClick={() => this.context.router.history.push(`/groups/${group.id}`)}
/>,
)}

View File

@ -1,29 +1,164 @@
import React from 'react';
import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
// import BigCalendar from 'react-big-calendar';
// import moment from 'moment';
// BigCalendar.setLocalizer(BigCalendar.momentLocalizer(moment));
import { Tabs, Tab, Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
import BigCalendar from 'react-big-calendar';
import moment from 'moment-timezone';
import '../calendar.css'; // Global styles
import AddEventDialog from '../components/addeventdialog';
BigCalendar.setLocalizer(BigCalendar.momentLocalizer(moment));
export default class PageHome extends React.Component {
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
this.state = {
cards: [
{ key: 0, title: 'Math Test', description: 'Meow' },
],
events: [],
start: new Date(),
end: new Date(Date.now() + (7 * 24 * 60 * 60 * 1000)),
view: 0,
addEventDialogActive: false,
center: new Date(),
};
this.context = context;
this.showAddEventDialog = this.showAddEventDialog.bind(this);
this.hideAddEventDialog = this.hideAddEventDialog.bind(this);
this.handleViewChange = this.handleViewChange.bind(this);
this.handleNavigateEvent = this.handleNavigateEvent.bind(this);
this.handleSelectEvent = this.handleSelectEvent.bind(this);
this.fetchEvents(context); // TODO: split into three backend calls
}
// eslint-disable-next-line class-methods-use-this
fetchEvents() {
// Do nothing
componentDidMount() {
this.context.tooling.setToolbar({
showPagination: false,
title: 'This Week\'s Agenda',
});
this.context.setPaginationNext(this.paginateNext);
this.context.setPaginationPrev(this.paginatePrev);
}
componentWillUnmount() {
this.context.tooling.setToolbar({
showPagination: false,
});
this.context.setPaginationNext(() => {});
this.context.setPaginationPrev(() => {});
}
async fetchEvents(context = this.context, start = this.state.start, end = this.state.end) {
const query = `start=${start.getTime()}&end=${end.getTime()}`;
return fetch(`/api/v1/schools/${context.user.school}/users/${context.user.id}/events?${query}`, {
headers: {
FakeAuth: true,
FakeID: context.user.id,
},
})
.then(data => data.json())
.then((data) => {
this.setState({
events: data.map(e => Object.assign({}, e, {
start: new Date(e.start),
end: new Date(e.end),
})),
});
console.log(`Fetched! ${data.length} events.`);
})
.catch((err) => {
console.error(err);
});
}
handleViewChange(value, center = this.state.center) {
let start = center;
let end = center;
if (value === 0) {
start = new Date();
end = new Date(
Date.now() + (7 * 24 * 60 * 60 * 1000),
);
this.context.tooling.setToolbar({
showPagination: false,
title: 'This Week\'s Agenda',
});
} else if (value === 1) {
start = new Date(
center.getFullYear(),
center.getMonth(),
center.getDate() - center.getDay(),
);
end = new Date(
center.getFullYear(),
center.getMonth(),
(center.getDate() - center.getDay()) + 7,
);
this.context.tooling.setToolbar({
showPagination: true,
title: `${moment(start).format('Do MMM')} to ${moment(end).format('Do MMM Y')}`,
});
} else if (value === 2) {
start = new Date(
center.getFullYear(),
center.getMonth() - 1,
23,
);
end = new Date(
center.getFullYear(),
center.getMonth() + 1,
6,
);
this.context.tooling.setToolbar({
showPagination: true,
title: moment(center).format('MMMM Y'),
});
}
if (value !== this.state.value || center !== this.state.center) {
console.log(`Will fetch... ${start} to ${end}`);
this.fetchEvents(this.context, start, end);
}
this.setState({
view: value,
start,
end,
center,
});
}
paginateNext() {
if (this.state.view === 1) {
this.handleNavigateEvent(new Date(this.state.center.getTime() + (7 * 24 * 60 * 60 * 1000)));
} else if (this.state.view === 2) {
this.handleNavigateEvent(new Date(
this.state.center.getFullYear(),
this.state.center.getMonth() + 1,
));
}
}
paginatePrev() {
if (this.state.view === 1) {
this.handleNavigateEvent(new Date(this.state.center.getTime() - (7 * 24 * 60 * 60 * 1000)));
} else if (this.state.view === 2) {
this.handleNavigateEvent(new Date(
this.state.center.getFullYear(),
this.state.center.getMonth() - 1,
));
}
}
handleNavigateEvent(date) {
this.setState({
center: date,
});
this.handleViewChange(this.state.view, date);
}
handleSelectEvent(event) {
this.context.router.history.push(`/events/${event.id}`);
}
showAddEventDialog() {
@ -41,17 +176,49 @@ export default class PageHome extends React.Component {
render() {
return (
<main>
<div style={{ padding: '1em' }}>
{this.state.cards.map(card =>
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
<CardTitle title={card.title} />
<CardText>{card.description}</CardText>
<CardActions>
<Button label="Edit" accent />
</CardActions>
</Card>,
)}
</div>
<Tabs index={this.state.view} onChange={this.handleViewChange} inverse>
<Tab label="Agenda">
{this.state.events.map(event =>
<Card key={`${event.id}@${event.start}`} style={{ margin: '1em', width: 'auto' }}>
<CardTitle title={event.name} />
<CardText>{`${moment(event.start).format('dddd, MMMM Do YYYY, HH:mm')} to ${moment(event.end).format('MMMM Do, HH:mm')}`}</CardText>
<CardActions>
<Button label="Edit" accent />
</CardActions>
</Card>,
)}
</Tab>
<Tab label="Week">
<div className="calendar">
<BigCalendar
view="week"
toolbar={false}
events={this.state.events}
date={this.state.center}
startAccessor="start"
endAccessor="end"
titleAccessor="name"
onSelectEvent={this.handleSelectEvent}
onNavigate={this.handleNavigateEvent}
/>
</div>
</Tab>
<Tab label="Month">
<div className="calendar">
<BigCalendar
view="month"
toolbar={false}
events={this.state.events}
date={this.state.center}
startAccessor="start"
endAccessor="end"
titleAccessor="name"
onSelectEvent={this.handleSelectEvent}
onNavigate={this.handleNavigateEvent}
/>
</div>
</Tab>
</Tabs>
<Button style={{ position: 'fixed', bottom: '1em', right: '1em' }} icon="add" floating accent onClick={this.showAddEventDialog} />
<AddEventDialog
active={this.state.addEventDialogActive}
@ -64,3 +231,16 @@ export default class PageHome extends React.Component {
);
}
}
PageHome.contextTypes = {
router: React.PropTypes.shape({
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
tooling: React.PropTypes.object.isRequired,
};

View File

@ -47,7 +47,7 @@
"express": "^4.14.1",
"jsonwebtoken": "^7.3.0",
"jwk-to-pem": "^1.2.6",
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"mysql": "^2.13.0",
"node-fetch": "^1.6.3",
"oidc-client": "^1.3.0-beta.3",

View File

@ -7,6 +7,8 @@ import jwkToPem from 'jwk-to-pem';
import { WebError, UnknownError, UnauthenticatedError, NotFoundError, InvalidCredentialsError, BadRequestError } from './errors';
import { computeOccurrences } from './utils';
export default class API {
constructor(database) {
this.database = database;
@ -119,6 +121,13 @@ export default class API {
});
// Events
this.router.post('/schools/:school/groups/:group/eventsWeekly/', this.auth, (req, res, next) => {
this.database.createEventWeekly(req.params.school, req.params.group, req.body)
.then((data) => {
res.json(data);
})
.catch(next);
});
this.router.post('/schools/:school/groups/:group/eventsOnce/', this.auth, (req, res, next) => {
this.database.createEventOnce(req.params.school, req.params.group, req.body)
.then((data) => {
@ -134,6 +143,23 @@ export default class API {
.catch(next);
});
this.router.get('/schools/:school/users/:id/events', this.auth, (req, res, next) => {
this.database.getUserEventsBetween(
req.params.school,
req.params.id,
req.query.start,
req.query.end,
)
.then((data) => {
const eventsWeekly = computeOccurrences(data.eventsWeekly, req.query.start, req.query.end);
res.json(data.eventsOnce
.map(e => Object.assign({ type: 'once' }, e))
.concat(eventsWeekly)
.map(e => Object.assign({ type: 'weekly' }, e)));
})
.catch(next);
});
this.router.use('/*', (req, res, next) => {
next(new NotFoundError());
});

View File

@ -1,7 +1,7 @@
import mysql from 'mysql';
import semver from 'semver';
import { fatal, getVersion } from './utils';
import { fatal, getVersion, stripTimezone } from './utils';
import { NotFoundError, attachNoun } from './errors';
const DATABASE = 'chronos';
@ -75,7 +75,7 @@ export default class Database {
}
async getUserGroups(school, id) {
return this.query(`
SELECT *
SELECT group_.*
FROM member, group_
WHERE member.group_ = group_.id
AND member.user = ?
@ -85,7 +85,7 @@ export default class Database {
async getGroups(school) {
return this.query(`
SELECT *
SELECT group_.*
FROM user, member, group_
WHERE member.group_ = group_.id
AND member.user = user.id
@ -130,21 +130,36 @@ export default class Database {
FROM event_once
WHERE event_once.group_ = ?
`, [id]);
return Promise.all([getGroup, getMembers, getEventsOnce])
const getEventsWeekly = this.query(`
SELECT *
FROM event_weekly
WHERE event_weekly.group_ = ?
`, [id]);
return Promise.all([getGroup, getMembers, getEventsOnce, getEventsWeekly])
.then(results => Object.assign({}, results[0], {
members: results[1].map(m => Object.assign(m, {
pwd_hash: undefined,
oid_id: undefined,
})),
eventsOnce: results[2],
eventsWeekly: results[3],
}));
}
async createEventWeekly(school, group, data) {
console.log(data.starttime);
return this.query(`
INSERT INTO event_weekly (group_, name, day, starttime, endtime)
VALUES (?, ?, ?, ?, ?)
`, [group, data.name, data.day, stripTimezone(data.starttime), stripTimezone(data.endtime)]);
}
async createEventOnce(school, group, data) {
console.log(data.starttime);
return this.query(`
INSERT INTO event_once (group_, name, start, end)
VALUES (?, ?, ?, ?)
`, [group, data.name, data.start, data.end]);
`, [group, data.name, stripTimezone(data.start), stripTimezone(data.end)]);
}
async getEventOnce(school, group, id) {
return this.query(`
@ -154,6 +169,7 @@ export default class Database {
AND event_once.id = ?
`, [group, id]);
}
// eslint-disable-next-line
async getEventClashesWith(school, group, id) {
// TODO
@ -161,6 +177,26 @@ export default class Database {
async getUserEventsBetween(school, user, start, end) {
// oh shit
const getEventsOnce = this.query(`
SELECT event_once.*
FROM member, event_once
WHERE member.group_ = event_once.group_
AND member.user = ?
AND (
(event_once.start >= ? AND event_once.start <= ?)
OR
(event_once.end >= ? AND event_once.end <= ?)
)
`, [user, stripTimezone(start), stripTimezone(end), stripTimezone(start), stripTimezone(end)]);
const getEventsWeekly = this.query(`
SELECT event_weekly.*
FROM member, event_weekly
WHERE member.group_ = event_weekly.group_
AND member.user = ?
`, [user]);
return Promise.all([getEventsOnce, getEventsWeekly])
.then(results => ({ eventsOnce: results[0], eventsWeekly: results[1] }));
}
query(query, values, options = {}) {

View File

@ -1,13 +1,93 @@
const getVersion = () => {
import moment from 'moment-timezone';
export function getVersion() {
if (process.env.npm_package_version) {
return process.env.npm_package_version;
}
throw new Error('Unable to obtain running version');
};
}
const fatal = (e) => {
export function fatal(e) {
console.error(e);
process.exit(1);
};
}
export { getVersion, fatal };
export function stripTimezone(date) {
// TODO:
// move timezone fixes to client side,
// let client send dates and times as strings to be directly stored into database
const d = new Date(date);
if (isNaN(d.getTime())) {
return new Date(parseInt(date, 10));
}
return d;
}
export function computeOccurrences(events, startd, endd) {
const SECONDS_IN_WEEK = 7 * 24 * 60 * 60;
const start = moment(stripTimezone(startd)).tz('Asia/Singapore');
const end = moment(stripTimezone(endd)).tz('Asia/Singapore');
const eventsOccurrences = [];
const secondsToPreviousStartOfWeek =
-(
(start.day() * 24 * 60 * 60) +
(start.hour() * 60 * 60) +
(start.minute() * 60) +
(start.second())
);
const secondsToEnd =
moment(end).unix() -
moment(start).unix();
const secondsToNextOccurrence = events.map((e) => {
const startT = e.starttime.split(':').map(c => parseInt(c, 10));
const endT = e.endtime.split(':').map(c => parseInt(c, 10));
const nextStart =
(e.day * 24 * 60 * 60) +
(startT[0] * 60 * 60) +
(startT[1] * 60) +
(startT[2]) +
secondsToPreviousStartOfWeek;
const nextEnd =
(e.day * 24 * 60 * 60) +
(endT[0] * 60 * 60) +
(endT[1] * 60) +
(endT[2]) +
secondsToPreviousStartOfWeek;
return {
start: nextStart,
end: nextEnd,
};
});
secondsToNextOccurrence.forEach((o) => {
while (o.end < 0) {
// eslint-disable-next-line no-param-reassign
o.start += SECONDS_IN_WEEK;
// eslint-disable-next-line no-param-reassign
o.end += SECONDS_IN_WEEK;
}
});
secondsToNextOccurrence.forEach((o, i) => {
while (o.start < secondsToEnd) {
const nextStart = moment(start).add(o.start, 'seconds');
const nextEnd = moment(start).add(o.end, 'seconds');
eventsOccurrences.push(Object.assign({}, events[i], {
start: nextStart,
end: nextEnd,
}));
// eslint-disable-next-line no-param-reassign
o.start += SECONDS_IN_WEEK;
// eslint-disable-next-line no-param-reassign
o.end += SECONDS_IN_WEEK;
}
});
return eventsOccurrences;
}