diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..2dbd5ef3 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["transform-flow-strip-types"] +} diff --git a/components/KeyboardListener.js b/components/KeyboardListener.js index e97cee5d..eb204f79 100644 --- a/components/KeyboardListener.js +++ b/components/KeyboardListener.js @@ -15,6 +15,10 @@ class KeyboardListener extends React.Component { } handleKeyDown(e: KeyboardEvent) { + if (document.activeElement && document.activeElement.tagName === 'INPUT') { + // Don't do shortcuts while input has focus. + return + } switch(e.code) { case 'ArrowUp': this.props.increaseFocusedMilestoneFn() diff --git a/components/LevelThermometer.js b/components/LevelThermometer.js index c570a777..6862d90e 100644 --- a/components/LevelThermometer.js +++ b/components/LevelThermometer.js @@ -61,7 +61,7 @@ class LevelThermometer extends React.Component { .style('text-anchor', 'start') } - rightRoundedRect(x, y, width, height, radius) { + rightRoundedRect(x: number, y: number, width: number, height: number, radius: number): string { return "M" + x + "," + y + "h" + (width - radius) + "a" + radius + "," + radius + " 0 0 1 " + radius + "," + radius diff --git a/components/SheetsControl.js b/components/SheetsControl.js new file mode 100644 index 00000000..bcea21db --- /dev/null +++ b/components/SheetsControl.js @@ -0,0 +1,245 @@ +// @flow + +import type { Milestone, MilestoneMap, NoteMap } from '../constants' +import { trackIds, tracks } from '../constants' + +import React from 'react' + +declare var gapi: any + +const API_KEY = 'AIzaSyCPZccI1B543VHblD__af_JvV2b8Z5-Lis' +const CLIENT_ID = '124466069863-0uic3ahingc9bst2oc95h29nvu30lrnu.apps.googleusercontent.com' + +const DISCOVERY_DOCS = ["https://sheets.googleapis.com/$discovery/rest?version=v4"] +const SCOPES = "https://www.googleapis.com/auth/spreadsheets" + +const RANGE = `B1:C${trackIds.length+3}` + +const DOCS_URL_REGEX = /^https:\/\/docs.google.com\/spreadsheets\/d\/([0-9a-zA-Z_\-]+)/ + +type Props = { + name: string, + title: string, + onImport: (name: string, title: string, milestones: Milestone[], notes: string[]) => void, + milestoneByTrack: MilestoneMap, + notesByTrack: NoteMap +} + +type State = { + isSignedIn: boolean, + sheetId: string +} + +export default class SheetsControl extends React.Component { + constructor(props: Props) { + super(props) + this.state = { + isSignedIn: false, + sheetId: '', + } + } + + componentDidMount() { + window.sheetsControl = this + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (this.state.isSignedIn && !prevState.isSignedIn) { + this.importSheet() + } + } + + componentWillUnmount() { + delete window.sheetsControl + } + + initClient() { + console.log('initing') + gapi.client.init({ + apiKey: API_KEY, + clientId: CLIENT_ID, + discoveryDocs: DISCOVERY_DOCS, + scope: SCOPES + }).then(() => { + console.log('promise resolved') + // Listen for sign-in state changes. + gapi.auth2.getAuthInstance().isSignedIn.listen(this.updateSigninStatus.bind(this)) + + // Handle the initial sign-in state. + this.updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get()) + }).catch(error => { + console.log('init failed', error) + }) + } + + updateSigninStatus(isSignedIn: boolean) { + console.log('signed in', isSignedIn) + this.setState({ isSignedIn }) + } + + render() { + const style = + + if (!this.state.isSignedIn) { + return ( +
+ {style} + +
+ ) + } else { + return ( +
+ {style} +
+ +
+ {this.state.sheetId && +
+ View Sheet +
} + + {this.state.sheetId + ? + : } + +
+ ) + } + } + + handleSheetChange(e: SyntheticEvent) { + const val = e.currentTarget.value + const match = val.match(DOCS_URL_REGEX) + if (match) { + // URL pasted in + this.setState({ sheetId: match[1] }) + } else { + this.setState({ sheetId: val }) + } + } + + handleAuthClick() { + gapi.auth2.getAuthInstance().signIn() + } + + importSheet() { + if (!this.state.sheetId) { + return + } + console.log('importing sheet', this.state.sheetId) + // Get stuff from sheet + gapi.client.sheets.spreadsheets.values.get({ + spreadsheetId: this.state.sheetId, + range: RANGE, + majorDimension: 'COLUMNS' + }).then(response => { + console.log('imported sheet') + const range = response.result + if (range.values.length > 0) { + // Special-case the first two rows + const name = range.values[0][0] + const title = range.values[1][0] + // Skip the third as they're just constant headers + const milestones = range.values[0].slice(3).map(val => parseInt(val[0])) + const notes = range.values[1].slice(3) + this.props.onImport(name, title, milestones, notes) + } else { + console.log('no values found') + } + }) + } + + handleSignOutClick() { + gapi.auth2.getAuthInstance().signOut() + } + + handleCreateClick() { + const rowValue = (val, bold) => ({ + userEnteredValue: { + [typeof val === 'number' ? 'numberValue' : 'stringValue']: val + }, + textFormatRuns: bold ? [ + { + startIndex: 0, + format: { bold: true } + } + ] : undefined + }) + const rows = trackIds.map(trackId => [ + tracks[trackId].displayName, + this.props.milestoneByTrack[trackId], + this.props.notesByTrack[trackId] + ]) + rows.unshift([ + 'Track', + 'Milestone', + 'Notes' + ]) + rows.unshift([ + 'Title', + this.props.title + ]) + rows.unshift([ + 'Name', + this.props.name + ]) + const data = rows.map((row, i) => ({ + startRow: i, + rowData: { + values: row.map((val, j) => rowValue(val, i === 2 || j === 0)) + } + })) + gapi.client.sheets.spreadsheets.create({ + properties: { + title: `${this.props.name}'s Snowflake` + }, + sheets: [ { data } ] + }).then(response => { + this.setState({ sheetId: response.result.spreadsheetId }) + }) + } + + handleSaveClick() { + const headers = [ + [this.props.name], + [this.props.title], + ['Milestone', 'Notes'] + ] + const values = trackIds.map(trackId => [ + this.props.milestoneByTrack[trackId], + this.props.notesByTrack[trackId] + ]) + gapi.client.sheets.spreadsheets.values.update({ + spreadsheetId: this.state.sheetId, + range: RANGE, + valueInputOption: 'USER_ENTERED', + resource: { + majorDimension: 'ROWS', + values: headers.concat(values) + } + }).then(() => { + console.log('saved') + }).catch(() => { + console.log('error saving') + }) + } +} diff --git a/components/SnowflakeApp.js b/components/SnowflakeApp.js index db61502f..50adff77 100644 --- a/components/SnowflakeApp.js +++ b/components/SnowflakeApp.js @@ -1,18 +1,21 @@ // @flow -import TrackSelector from '../components/TrackSelector' -import NightingaleChart from '../components/NightingaleChart' +import type { Milestone, MilestoneMap, NoteMap, TrackId } from '../constants' +import { eligibleTitles, milestoneToPoints, milestones, trackIds } from '../constants' + import KeyboardListener from '../components/KeyboardListener' -import Track from '../components/Track' -import Wordmark from '../components/Wordmark' import LevelThermometer from '../components/LevelThermometer' -import { eligibleTitles, trackIds, milestones, milestoneToPoints } from '../constants' +import NightingaleChart from '../components/NightingaleChart' import PointSummaries from '../components/PointSummaries' -import type { Milestone, MilestoneMap, TrackId } from '../constants' +import SheetsControl from '../components/SheetsControl' import React from 'react' import TitleSelector from '../components/TitleSelector' +import Track from '../components/Track' +import TrackSelector from '../components/TrackSelector' +import Wordmark from '../components/Wordmark' type SnowflakeAppState = { + notesByTrack: NoteMap, milestoneByTrack: MilestoneMap, name: string, title: string, @@ -47,13 +50,13 @@ const coerceMilestone = (value: number): Milestone => { const emptyState = (): SnowflakeAppState => { return { - name: '', + name: 'Enter Your Name Here', title: '', milestoneByTrack: { 'MOBILE': 0, 'WEB_CLIENT': 0, - 'FOUNDATIONS': 0, - 'SERVERS': 0, + 'FOUNDATIONS (PLATFORM)': 0, + 'SERVERS & API': 0, 'PROJECT_MANAGEMENT': 0, 'COMMUNICATION': 0, 'CRAFT': 0, @@ -67,6 +70,7 @@ const emptyState = (): SnowflakeAppState => { 'RECRUITING': 0, 'COMMUNITY': 0 }, + notesByTrack: {}, focusedTrackId: 'MOBILE' } } @@ -78,8 +82,8 @@ const defaultState = (): SnowflakeAppState => { milestoneByTrack: { 'MOBILE': 1, 'WEB_CLIENT': 2, - 'FOUNDATIONS': 3, - 'SERVERS': 2, + 'FOUNDATIONS (PLATFORM)': 3, + 'SERVERS & API': 2, 'PROJECT_MANAGEMENT': 4, 'COMMUNICATION': 1, 'CRAFT': 1, @@ -93,6 +97,7 @@ const defaultState = (): SnowflakeAppState => { 'RECRUITING': 3, 'COMMUNITY': 0 }, + notesByTrack: {}, focusedTrackId: 'MOBILE' } } @@ -156,7 +161,7 @@ class SnowflakeApp extends React.Component { } `} @@ -170,6 +175,12 @@ class SnowflakeApp extends React.Component { onChange={e => this.setState({name: e.target.value})} placeholder="Name" /> + { decreaseFocusedMilestoneFn={this.shiftFocusedTrackMilestoneByDelta.bind(this, -1)} /> this.handleTrackMilestoneChange(track, milestone)} /> + handleTrackMilestoneChangeFn={(track, milestone) => this.handleTrackMilestoneChange(track, milestone)} + handleTrackNoteChangeFn={(track, note) => this.handleTrackNoteChange(track, note)} />
+
+ Forked from Medium Eng. Learn about the growth framework. Get the source code. Read the terms of service. @@ -210,6 +226,18 @@ class SnowflakeApp extends React.Component { ) } + handleSheetsImport(name: string, title: string, milestones: Milestone[], notes: string[]) { + const milestoneByTrack = {} + milestones.forEach((milestone, i) => { + milestoneByTrack[trackIds[i]] = milestone + }) + const notesByTrack = {} + notes.forEach((note, i) => { + notesByTrack[trackIds[i]] = note + }) + this.setState({ name, title, milestoneByTrack, notesByTrack }) + } + handleTrackMilestoneChange(trackId: TrackId, milestone: Milestone) { const milestoneByTrack = this.state.milestoneByTrack milestoneByTrack[trackId] = milestone @@ -220,6 +248,13 @@ class SnowflakeApp extends React.Component { this.setState({ milestoneByTrack, focusedTrackId: trackId, title }) } + handleTrackNoteChange(trackId: TrackId, note: string) { + const notesByTrack = Object.assign({}, this.state.notesByTrack) + notesByTrack[trackId] = note + + this.setState({ notesByTrack }) + } + shiftFocusedTrack(delta: number) { let index = trackIds.indexOf(this.state.focusedTrackId) index = (index + delta + trackIds.length) % trackIds.length @@ -236,9 +271,7 @@ class SnowflakeApp extends React.Component { shiftFocusedTrackMilestoneByDelta(delta: number) { let prevMilestone = this.state.milestoneByTrack[this.state.focusedTrackId] let milestone = prevMilestone + delta - if (milestone < 0) milestone = 0 - if (milestone > 5) milestone = 5 - this.handleTrackMilestoneChange(this.state.focusedTrackId, milestone) + this.handleTrackMilestoneChange(this.state.focusedTrackId, coerceMilestone(milestone)) } setTitle(title: string) { diff --git a/components/TitleSelector.js b/components/TitleSelector.js index ec9b1d2e..2212ca54 100644 --- a/components/TitleSelector.js +++ b/components/TitleSelector.js @@ -6,11 +6,11 @@ import type { MilestoneMap } from '../constants' type Props = { milestoneByTrack: MilestoneMap, - currentTitle: String, + currentTitle: string, setTitleFn: (string) => void } -class TitleSelector extends React.Component { +class TitleSelector extends React.Component { render() { const titles = eligibleTitles(this.props.milestoneByTrack) return