Show Calendar and animate buble

This commit is contained in:
Manuel Bouza
2019-02-21 16:12:18 +01:00
parent 859a27e4a1
commit 0c07baa598
12 changed files with 232 additions and 63 deletions

View File

@@ -24,6 +24,7 @@
"react": "^16.8.0", "react": "^16.8.0",
"react-dom": "^16.8.0", "react-dom": "^16.8.0",
"react-select": "^2.3.0", "react-select": "^2.3.0",
"react-spring": "^8.0.7",
"route-parser": "^0.0.5" "route-parser": "^0.0.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -66,7 +66,7 @@ input[name="hours"] {
textarea[name="description"] { textarea[name="description"] {
resize: none; resize: none;
width: calc(100% - 1rem); width: calc(100% - 1rem - 2px);
} }
button { button {

View File

@@ -60,8 +60,8 @@
box-sizing: content-box; box-sizing: content-box;
background-color: white; background-color: white;
width: 600px; width: 600px;
height: 400px; height: 540px;
padding: 2rem; padding: 3rem;
margin: 0 auto; margin: 0 auto;
} }
} }

View File

@@ -5,6 +5,7 @@ html {
height: 100%; height: 100%;
body { body {
margin: 0;
height: 100%; height: 100%;
#moco-bx-root { #moco-bx-root {
@@ -27,6 +28,54 @@ html {
margin: 0; margin: 0;
} }
} }
.moco-bx-calendar {
display: flex;
justify-content: space-between;
margin-bottom: 3rem;
.moco-bx-calendar__day {
display: flex;
flex-flow: column nowrap;
align-items: center;
flex: 0 0 48px;
width: 48px;
height: 60px;
cursor: pointer;
.moco-bx-calendar__hours {
display: flex;
justify-content: center;
align-items: center;
width: 48px;
height: 48px;
flex: 0 0 48px;
color: white;
background-color: #eee;
}
&.moco-bx-calendar__day--filled {
.moco-bx-calendar__hours {
background-color: #7dc332;
}
}
&.moco-bx-calendar__day--week-day-6,
&.moco-bx-calendar__day--week-day-0 {
.moco-bx-calendar__hours {
background-color: #bbb;
}
}
&.moco-bx-calendar__day--active {
.moco-bx-calendar__hours {
background-color: #38b5eb;
}
}
}
}
} }
} }
} }

View File

@@ -1,4 +1,5 @@
import axios from "axios" import axios from "axios"
import { formatDate } from "utils"
export default class Client { export default class Client {
#client; #client;
@@ -20,14 +21,17 @@ export default class Client {
}) })
} }
login = () => this.#client.post("session", { api_key: this.#apiKey });
projects = () => this.#client.get("projects"); projects = () => this.#client.get("projects");
activities = (fromDate, toDate) =>
this.#client.get("activities", {
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` }
})
bookedHours = service => bookedHours = service =>
this.#client.get("activities/tags", { this.#client.get("activities/tags", {
params: { selection: [service.id], remote_service: service.name } params: { selection: [service.id], remote_service: service.name }
}); })
createActivity = activity => this.#client.post("activities", { activity }); createActivity = activity => this.#client.post("activities", { activity });
} }

View File

@@ -2,17 +2,19 @@ import React, { Component } from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import ApiClient from "api/Client" import ApiClient from "api/Client"
import Form from "components/Form" import Form from "components/Form"
import Calendar from "components/Calendar"
import Spinner from "components/Spinner" import Spinner from "components/Spinner"
import { observable, computed, toJS } from "mobx" import { observable, computed } from "mobx"
import { observer, disposeOnUnmount } from "mobx-react" import { observer } from "mobx-react"
import { import {
findLastProject, findLastProject,
findLastTask, findLastTask,
groupedProjectOptions, groupedProjectOptions,
currentDate, formatDate,
secondsFromHours secondsFromHours
} from "utils" } from "utils"
import logoUrl from 'images/logo.png' import { startOfWeek, endOfWeek } from "date-fns"
import logoUrl from "images/logo.png"
import { head } from "lodash" import { head } from "lodash"
@observer @observer
@@ -31,13 +33,14 @@ class App extends Component {
apiKey: PropTypes.string, apiKey: PropTypes.string,
version: PropTypes.string version: PropTypes.string
}) })
}; }
@observable projects = [] @observable projects = []
@observable activities = []
@observable lastProjectId @observable lastProjectId
@observable lastTaskId @observable lastTaskId
@observable changeset = {}; @observable changeset = {}
@observable formErrors = {}; @observable formErrors = {}
@observable isLoading = true @observable isLoading = true
@computed get changesetWithDefaults() { @computed get changesetWithDefaults() {
@@ -55,7 +58,7 @@ class App extends Component {
remote_service: service.name, remote_service: service.name,
remote_id: service.id, remote_id: service.id,
remote_url: service.url, remote_url: service.url,
date: currentDate(), date: formatDate(new Date()),
assignment_id: project?.value, assignment_id: project?.value,
task_id: task?.value, task_id: task?.value,
billable: task?.billable, billable: task?.billable,
@@ -78,7 +81,8 @@ class App extends Component {
} }
componentDidMount() { componentDidMount() {
this.fetchProjects() Promise.all([this.fetchProjects(), this.fetchActivities()])
.then(() => this.isLoading = false)
window.addEventListener("keydown", this.handleKeyDown) window.addEventListener("keydown", this.handleKeyDown)
} }
@@ -90,23 +94,31 @@ class App extends Component {
this.#apiClient = new ApiClient(settings) this.#apiClient = new ApiClient(settings)
} }
fetchProjects = () => { fromDate = () => startOfWeek(new Date(), { weekStartsOn: 1 })
this.isLoading = true toDate = () => endOfWeek(new Date(), { weekStartsOn: 1 })
return this.#apiClient fetchProjects = () =>
this.#apiClient
.projects() .projects()
.then(({ data }) => { .then(({ data }) => {
this.projects = groupedProjectOptions(data.projects) this.projects = groupedProjectOptions(data.projects)
this.lastProjectId = data.last_project_id this.lastProjectId = data.last_project_id
this.lastTaskId = data.lastTaskId this.lastTaskId = data.lastTaskId
}) })
.catch(error => { .catch(() => {
this.sendMessage({ type: 'closeForm' }) this.sendMessage({ type: "closeForm" })
}) })
.finally(() => {
this.isLoading = false fetchActivities = () => {
return this.#apiClient
.activities(this.fromDate(), this.toDate())
.then(({ data }) => {
this.activities = data
}) })
}; .catch(() => {
this.sendMessage({ type: "closeForm" })
})
}
createActivity = () => { createActivity = () => {
this.isLoading = true this.isLoading = true
@@ -116,9 +128,10 @@ class App extends Component {
.then(({ data }) => { .then(({ data }) => {
this.changeset = {} this.changeset = {}
this.formErrors = {} this.formErrors = {}
this.sendMessage( this.sendMessage({
{ type: 'activityCreated', payload: { hours: data.hours} } type: "activityCreated",
) payload: { hours: data.hours }
})
}) })
.catch(error => { .catch(error => {
if (error.response?.status === 422) { if (error.response?.status === 422) {
@@ -128,15 +141,15 @@ class App extends Component {
this.unauthorizedError = true this.unauthorizedError = true
} }
}) })
.finally(() => this.isLoading = false) .finally(() => (this.isLoading = false))
} }
handleKeyDown = event => { handleKeyDown = event => {
event.stopPropagation() event.stopPropagation()
if (event.keyCode === 27) { if (event.keyCode === 27) {
this.sendMessage({ type: 'closeForm' }) this.sendMessage({ type: "closeForm" })
} }
}; }
handleChange = event => { handleChange = event => {
const { const {
@@ -148,7 +161,11 @@ class App extends Component {
if (name === "assignment_id") { if (name === "assignment_id") {
this.changeset.task_id = null this.changeset.task_id = null
} }
}; }
handleSelectDate = date => {
this.changeset.date = formatDate(date)
}
handleSubmit = event => { handleSubmit = event => {
event.preventDefault() event.preventDefault()
@@ -156,13 +173,12 @@ class App extends Component {
} }
handleCancel = () => { handleCancel = () => {
this.sendMessage({ type: 'closeForm' }) this.sendMessage({ type: "closeForm" })
} }
sendMessage = action => sendMessage = action =>
chrome.tabs.query( chrome.tabs.query({ active: true, currentWindow: true }, tabs =>
{ active: true, currentWindow: true }, chrome.tabs.sendMessage(tabs[0].id, action)
tabs => chrome.tabs.sendMessage(tabs[0].id, action)
) )
render() { render() {
@@ -170,15 +186,23 @@ class App extends Component {
return <Spinner /> return <Spinner />
} }
const { service } = this.props;
return ( return (
<> <>
<div className="moco-bx-logo__container"> <div className="moco-bx-logo__container">
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} /> <img
className="moco-bx-logo"
src={chrome.extension.getURL(logoUrl)}
/>
<h1>MOCO Zeiterfassung</h1> <h1>MOCO Zeiterfassung</h1>
</div> </div>
<Calendar
fromDate={this.fromDate()}
toDate={this.toDate()}
activities={this.activities}
selectedDate={new Date(this.changesetWithDefaults.date)}
onChange={this.handleSelectDate}
/>
<Form <Form
changeset={this.changesetWithDefaults} changeset={this.changesetWithDefaults}
projects={this.projects} projects={this.projects}

View File

@@ -1,21 +1,12 @@
import React, { Component } from "react" import React, { Component } from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { Spring, config, animated } from 'react-spring/renderprops'
import ApiClient from "api/Client" import ApiClient from "api/Client"
import Popup from "components/Popup" import Popup from "components/Popup"
import InvalidConfigurationError from "components/InvalidConfigurationError"
import Form from "components/Form"
import Spinner from "components/Spinner" import Spinner from "components/Spinner"
import { observable, computed, reaction } from "mobx" import { observable, reaction } from "mobx"
import { observer, disposeOnUnmount } from "mobx-react" import { observer, disposeOnUnmount } from "mobx-react"
import logoUrl from "images/logo.png" import logoUrl from "images/logo.png"
import {
findLastProject,
findLastTask,
groupedProjectOptions,
currentDate,
secondsFromHours
} from "utils"
import { head } from "lodash"
@observer @observer
class Bubble extends Component { class Bubble extends Component {
@@ -75,7 +66,7 @@ class Bubble extends Component {
initializeApiClient = settings => { initializeApiClient = settings => {
this.#apiClient = new ApiClient(settings) this.#apiClient = new ApiClient(settings)
} }
open = event => { open = event => {
if (event && event.target && event.target.classList.contains('moco-bx-popup')) { if (event && event.target && event.target.classList.contains('moco-bx-popup')) {
return this.close() return this.close()
@@ -139,20 +130,30 @@ class Bubble extends Component {
const { service, settings } = this.props const { service, settings } = this.props
return ( return (
<div className="moco-bx-bubble" onClick={this.open} style={service.position}> <>
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} /> <Spring from={{ transform: 'scale(0.1)' }} to={{ transform: 'scale(1)' }} config={config.wobbly}>
{this.bookedHours > 0 {props => (
? <span className="moco-bx-badge">{this.bookedHours}h</span> <animated.div
: null className="moco-bx-bubble"
} style={{...service.position, ...props}}
onClick={this.open}
>
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
{this.bookedHours > 0
? <span className="moco-bx-badge">{this.bookedHours}h</span>
: null
}
</animated.div>
)}
</Spring>
{this.isOpen && ( {this.isOpen && (
<Popup <Popup
service={service} service={service}
settings={settings} settings={settings}
unauthorizedError={this.unauthorizedError} unauthorizedError={this.unauthorizedError}
/> />
)} )}
</div> </>
) )
} }
} }

View File

@@ -0,0 +1,37 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import { format, getDay } from 'date-fns'
import deLocale from 'date-fns/locale/de'
import cn from 'classnames'
const Day = ({ date, hours, active, onClick }) => {
const handleClick = useCallback(() => onClick(date), [date])
return (
<div
className={cn(
'moco-bx-calendar__day',
`moco-bx-calendar__day--week-day-${getDay(date)}`,
{
'moco-bx-calendar__day--active': active,
'moco-bx-calendar__day--filled': hours > 0
}
)}
onClick={handleClick}
>
<span className='moxo-bx-calendar__day-of-week'>
{format(date, 'dd', { locale: deLocale })}
</span>
<span className='moco-bx-calendar__hours'>{hours > 0 ? hours : null}</span>
</div>
)
}
Day.propTypes = {
date: PropTypes.instanceOf(Date).isRequired,
hours: PropTypes.number.isRequired,
active: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired
}
export default Day

View File

@@ -0,0 +1,46 @@
import React from 'react'
import PropTypes from 'prop-types'
import Day from './Day'
import { formatDate, trace } from 'utils'
import { eachDay } from 'date-fns'
import { pathEq } from 'lodash/fp'
const hoursInDate = activities => date =>
activities
.filter(pathEq('date', formatDate(date)))
.reduce((acc, activity) => acc + activity.hours, 0)
const Calendar = ({ fromDate, toDate, selectedDate, activities, onChange }) => {
const getHours = hoursInDate(activities)
return (
<div className='moco-bx-calendar'>
{eachDay(fromDate, toDate).map(date => (
<Day
key={date}
date={date}
hours={getHours(date)}
onClick={onChange}
active={trace('dateL', formatDate(date)) === trace('dateR', formatDate(selectedDate))}
/>
))}
</div>
)
}
Calendar.propTypes = {
fromDate: PropTypes.instanceOf(Date).isRequired,
toDate: PropTypes.instanceOf(Date).isRequired,
selectedDate: PropTypes.instanceOf(Date).isRequired,
activities: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
date: PropTypes.string.isRequired,
hours: PropTypes.number.isRequired,
timer_started_at: PropTypes.string
}).isRequired
),
onChange: PropTypes.func.isRequired
}
export default Calendar

View File

@@ -9,7 +9,7 @@ const Popup = props => {
const styles = useMemo(() => ({ const styles = useMemo(() => ({
width: '536px', width: '536px',
height: props.unauthorizedError ? '890px' : '400px' height: props.unauthorizedError ? '890px' : '470px'
}), [props.unauthorizedError]) }), [props.unauthorizedError])
return ( return (

View File

@@ -73,8 +73,8 @@ export const trace = curry((tag, value) => {
return value return value
}) })
export const currentDate = (locale = 'de') => export const formatDate = date =>
format(new Date(), 'YYYY-MM-DD', { locale }) format(date, 'YYYY-MM-DD')
export const extensionSettingsUrl = () => export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}` `chrome://extensions/?id=${chrome.runtime.id}`

View File

@@ -667,7 +667,7 @@
"@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-self" "^7.0.0"
"@babel/plugin-transform-react-jsx-source" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0"
"@babel/runtime@^7.1.2": "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1":
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a"
integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA== integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA==
@@ -5983,6 +5983,13 @@ react-select@^2.3.0:
react-input-autosize "^2.2.1" react-input-autosize "^2.2.1"
react-transition-group "^2.2.1" react-transition-group "^2.2.1"
react-spring@^8.0.7:
version "8.0.7"
resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.7.tgz#55b1d298be16b308388cb865ee707a1194112fe0"
integrity sha512-QHEjWLvEuRCGFRlR29o03CMc+uN1QMPt8Gxxp37szpnkJUq+HDIWNXNCacQ+kJxqzcu65JoeWV6D5q3/y5VK6Q==
dependencies:
"@babel/runtime" "^7.3.1"
react-transition-group@^2.2.1: react-transition-group@^2.2.1:
version "2.5.3" version "2.5.3"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.3.tgz#26de363cab19e5c88ae5dbae105c706cf953bb92" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.3.tgz#26de363cab19e5c88ae5dbae105c706cf953bb92"