Skip to content

Commit

Permalink
enh: task tags
Browse files Browse the repository at this point in the history
  • Loading branch information
voidpp committed Sep 29, 2019
1 parent 45d3a62 commit fe82f27
Show file tree
Hide file tree
Showing 14 changed files with 128 additions and 39 deletions.
16 changes: 16 additions & 0 deletions pabu/assets/ts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ export function fetchProjects(id: number = null, ) {
}
}

export function fetchTags(projectId: number) {
return dispatch => {
return client.getTags(projectId).then(data => {
dispatch(receiveTags(data))
})
}
}

export function fetchProjectUsers(projectId: number) {
return dispatch => {
return client.getProjectUsers(projectId).then(data => {
Expand Down Expand Up @@ -282,6 +290,14 @@ export function receiveIssues(data, deepUpdate = false) {
}
}

export function receiveTags(data, deepUpdate = false) {
return {
type: Action.RECEIVE_TAGS,
data,
deepUpdate,
}
}

export function receiveTimeEntries(data) {
return {
type: Action.RECEIVE_TIME_ENTRIES,
Expand Down
4 changes: 4 additions & 0 deletions pabu/assets/ts/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class PabuClient {
return this._send('get_time_entries', [project_id]);
}

async getTags(project_id: number): Promise<TagMap> {
return this._send('get_tags', [project_id]);
}

async getProjectUsers(project_id: number) {
return this._send('get_project_users', [project_id]);
}
Expand Down
15 changes: 12 additions & 3 deletions pabu/assets/ts/components/IssueCardView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createStyles, Theme, Typography, withStyles, Avatar, Tooltip, Link } from "@material-ui/core";
import { createStyles, Theme, Typography, withStyles, Avatar, Tooltip, Link, Chip } from "@material-ui/core";
import * as React from 'react';
import { DragDropContext, Draggable, Droppable, DroppableStateSnapshot, DropResult } from "react-beautiful-dnd";
import { IssueByStatusMap, IssueStatus, UserMap, TickingStat, Issue, User } from "../types";
import { IssueByStatusMap, IssueStatus, UserMap, TickingStat, Issue, User, TagMap } from "../types";
import classNames = require("classnames");
import AccountCircle from '@material-ui/icons/AccountCircle';
import StopWatch from "../containers/StopWatch";
Expand All @@ -27,6 +27,7 @@ const styles = ({ palette, shape, typography }: Theme) => createStyles({

export type StateProps = {
issues: IssueByStatusMap,
tags: TagMap,
}

export type DispatchProps = {
Expand All @@ -45,6 +46,7 @@ export type OwnProps = {
stopTime: () => void,
doneDateFilter: number,
tagFilter: Array<number>,
setTagFilter: (id: number) => void,
}

type MuiProps = {
Expand All @@ -62,11 +64,13 @@ type CardContentProps = {
startTime: (projectId: number, issueId: number) => void,
stopTime: () => void,
issue: Issue,
tags: TagMap,
setTagFilter: (id: number) => void,
}

const CardContent = withStyles(styles)(React.memo((props: CardContentProps & MuiProps) => {

const {showIssue, issue} = props;
const {showIssue, issue, tags, setTagFilter} = props;

return <React.Fragment>
<div className="header">
Expand All @@ -75,6 +79,11 @@ const CardContent = withStyles(styles)(React.memo((props: CardContentProps & Mui
</Typography>
<IssueUserIcon issue={issue}/>
</div>
<div style={{flexGrow: 1, display: 'flex', alignItems: 'center'}}>
{issue.tags.map(id => (
<Chip style={{marginRight: 5}} size="small" label={tags[id].name} onClick={() => setTagFilter(id)} />
))}
</div>
<div className="footer">
<Typography style={{opacity: 0.6, flexGrow: 1}}>
<StopWatch projectId={issue.projectId} issueId={issue.id} initialValue={issue.timeStat.spent} />
Expand Down
1 change: 1 addition & 0 deletions pabu/assets/ts/components/IssueFormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class IssueFormDialog extends React.Component<Props, State> {
fullWidth
/>
<MultiSelect
creatable
label="Tags"
values={tags.map(t => ({label: t, value: t}))}
options={this.props.tags.map(t => ({label: t, value: t}))}
Expand Down
15 changes: 12 additions & 3 deletions pabu/assets/ts/components/IssueList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class IssueList extends React.Component<Props, State> {
layout: pabuLocalStorage.issueListLayout,
statusFilters: pabuLocalStorage.issueTableFilters,
doneDateFilter: pabuLocalStorage.issueDoneDateFilter,
tagFilter: [],
tagFilter: pabuLocalStorage.issueTagFilter,
}
}

Expand All @@ -130,6 +130,11 @@ class IssueList extends React.Component<Props, State> {
pabuLocalStorage.issueDoneDateFilter = value;
}

private changeTagFilter = (tagFilter: Array<number>) => {
this.setState({tagFilter});
pabuLocalStorage.issueTagFilter = tagFilter;
}

render() {
// TODO: issue filtering implemented twice, once for IssueCardView and once for IssueTableView... merge them!
const {onAddNewIssue, id, classes} = this.props;
Expand All @@ -156,13 +161,17 @@ class IssueList extends React.Component<Props, State> {
placeholder="Filter by tags..."
options={Object.values(this.props.tags).map(t => ({label: t.name, value: t.id}))}
values={tagFilter.map(id => ({value: id, label: this.props.tags[id].name}))}
onChange={v => this.setState({tagFilter: v.map(v => v.value)})}
onChange={v => this.changeTagFilter(v.map(v => v.value))}
/>
</div>
</div>
{layout == 'list' ?
<IssueTableView {...this.props} issues={issues}/> :
<IssueCardView tagFilter={tagFilter} doneDateFilter={doneDateFilter} projectId={id}
<IssueCardView
tagFilter={tagFilter}
doneDateFilter={doneDateFilter}
projectId={id}
setTagFilter={id => this.changeTagFilter([id])}
{...removeKeys<Props>(this.props, 'classes')}
/>}
</div>
Expand Down
2 changes: 1 addition & 1 deletion pabu/assets/ts/components/IssueViewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export default React.memo((props: StateProps & DispatchProps) => {
<tr>
<td style={{verticalAlign: 'center'}}><Typography>Tags</Typography></td>
<td>
{issue.tags.map(id => <Chip style={{marginRight: 5}} key={id} label={tags[id].name} />)}
{issue.tags.map(id => <Chip size="small" style={{marginRight: 5}} key={id} label={tags[id].name} />)}
{issue.tags.length == 0 ? <NoDataLabel text="There are no tags" /> : ''}
</td>
</tr>
Expand Down
8 changes: 5 additions & 3 deletions pabu/assets/ts/components/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ function MultiValue(props) {
[props.selectProps.classes.chipFocused]: props.isFocused,
})}
onDelete={props.removeProps.onClick}
deleteIcon={<CancelIcon {...props.removeProps} />}
style={{height: 28}}
deleteIcon={<CancelIcon {...props.removeProps} fontSize="small" style={{marginLeft: -5, marginRight: 2}} />}
size="small"
/>
);
}
Expand Down Expand Up @@ -182,14 +182,15 @@ export type Props = {
label?: string,
placeholder?: string,
creatable?: boolean,
style?: React.CSSProperties,
};

export default function MultiSelect(props: Props) {
const classes = useStyles({});

const theme = useTheme();

const {values, options, onChange, label, placeholder = false, creatable = false} = props;
const {values, options, onChange, label, placeholder = false, creatable = false, style = {}} = props;

const selectStyles = {
input: base => ({
Expand All @@ -207,6 +208,7 @@ export default function MultiSelect(props: Props) {
<Comp
classes={classes}
styles={selectStyles}
style={style}
inputId="react-select-multiple"
TextFieldProps={{
label,
Expand Down
53 changes: 44 additions & 9 deletions pabu/assets/ts/components/TimeEntryList.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import * as React from 'react';
import PabuTable, {TableColDesriptor} from './PabuTable';
import { PabuModel, ExpandedTimeEntry, TickingStat, TimeEntry, IssueStatus } from '../types';
import moment = require('moment');
import { formatDuration } from '../tools';
import { Button, Link } from '@material-ui/core';
import * as React from 'react';
import StopWatch from '../containers/StopWatch';
import { formatDuration } from '../tools';
import { ExpandedTimeEntry, IssueStatus, Tag, TickingStat, TimeEntry } from '../types';
import PabuTable, { TableColDesriptor } from './PabuTable';
import moment = require('moment');
import MultiSelect from './MultiSelect';

export type OwnProps = {
id: number,
}

export type StateProps = {
rows: Array<PabuModel>,
rows: Array<ExpandedTimeEntry>,
tickingStat: TickingStat,
tags: Array<Tag>,
}

export type DispatchProps = {
Expand All @@ -28,11 +30,13 @@ const renderIssueLink = (entry: ExpandedTimeEntry, showIssue): React.ReactNode =
return <Link style={{cursor: 'pointer', textDecoration}} onClick={() => showIssue(entry.issueId)}>#{entry.issueId}</Link>
}

export default React.memo((props: StateProps & DispatchProps & OwnProps) => {
const {tickingStat, rows, onDelete, onAddNewTime, onStartTime, onStopTime, id, showIssue} = props;
export default ((props: StateProps & DispatchProps & OwnProps) => {
const {tickingStat, rows, onDelete, onAddNewTime, onStartTime, onStopTime, id, showIssue, tags} = props;

const lengthFormatter = (v: number, entry: TimeEntry) => entry.end ? formatDuration(v) : <StopWatch projectId={id} initialValue={v} />

const [tagFilter, setTagFilter] = React.useState<Array<number>>([]);

const rowDescriptors = [
new TableColDesriptor('start', 'Start', v => moment.unix(v).format('YYYY-MM-DD HH:mm')),
new TableColDesriptor('spentHours' , 'Length', lengthFormatter),
Expand All @@ -52,5 +56,36 @@ export default React.memo((props: StateProps & DispatchProps & OwnProps) => {
<Button size="small" color="primary" onClick={onAddNewTime.bind(this, id)}>Add</Button>
</div>

return <PabuTable colDescriptors={rowDescriptors} rows={rows} onDelete={onDelete} controllCellHeader={controllCellHeader} />
const filteredRows = tagFilter.length ? rows.filter(r => r.issueTags.filter(id => tagFilter.includes(id)).length == tagFilter.length) : rows;

function timeSum(data: Array<ExpandedTimeEntry>): number {
return data.reduce((prev, curr) => prev + curr.spentHours, 0);
}

const timeStat = {
allSpent: formatDuration(timeSum(rows)),
filteredSpent: formatDuration(timeSum(filteredRows)),
};

return (
<React.Fragment>
<div style={{padding: 10, display: 'flex', flexDirection: 'row', alignItems: 'center'}}>
<div style={{flexGrow: 1}}>
<MultiSelect
label="Issue tag filter"
placeholder="Select tags for issue filter"
options={tags.map(t => ({label: t.name, value: t.id}))}
values={tags.filter(t => tagFilter.includes(t.id)).map(t => ({label: t.name, value: t.id}))}
onChange={v => setTagFilter(v.map(v => v.value))}
/>
</div>
<div style={{marginLeft: 10}}>
Time spent summary:<br/>
{timeStat.filteredSpent}
{timeStat.allSpent != timeStat.filteredSpent ? (<span> / {timeStat.allSpent}</span>) : ''}
</div>
</div>
<PabuTable colDescriptors={rowDescriptors} rows={filteredRows} onDelete={onDelete} controllCellHeader={controllCellHeader} />
</React.Fragment>
)
})
3 changes: 2 additions & 1 deletion pabu/assets/ts/containers/IssueCardView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ServerIssueData, State, ThunkDispatcher, IssueStatus, IssueByStatusMap
import { DropResult } from 'react-beautiful-dnd';

function mapStateToProps(state: State, props: OwnProps): StateProps {
let {issues} = state;
let {issues, tags} = state;

let issuesByStatus: IssueByStatusMap = {};
Object.values(IssueStatus).map(s => {issuesByStatus[s] = []})
Expand All @@ -23,6 +23,7 @@ function mapStateToProps(state: State, props: OwnProps): StateProps {

return {
issues: issuesByStatus,
tags,
}
}

Expand Down
5 changes: 3 additions & 2 deletions pabu/assets/ts/containers/ProjectList.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import { connect } from 'react-redux';
import { closeAddTimeDialog, closeIssueDialog, closePaymentDialog, closeProject, fetchAllProjectDataIfNeeded, fetchIssues, fetchProjects,
openProject, processIssues, processTags, sendPayment, sendTime, showNotification } from '../actions';
openProject, processIssues, processTags, sendPayment, sendTime, showNotification, fetchTags } from '../actions';
import ProjectList, { DispatchProps, StateProps } from '../components/ProjectList';
import { IssueFormData, IssueStatus, PaymentSubmitData, Project, State, ThunkDispatcher } from '../types';

Expand Down Expand Up @@ -34,8 +34,9 @@ const mapDispatchToProps = (dispatch: ThunkDispatcher) => {
const issues = await dispatch(processIssues([{name, desc, status, projectId, id}]));
await dispatch(processTags(Object.values(issues)[0].id, tags));
dispatch(closeIssueDialog());
dispatch(fetchIssues(projectId));
dispatch(fetchProjects(projectId));
dispatch(fetchIssues(projectId));
dispatch(fetchTags(projectId));
dispatch(showNotification(`Task has been ${id ? 'modified': 'created'}`));
},
onPaymentSubmit: (projectId: number, data: PaymentSubmitData) => {
Expand Down
13 changes: 9 additions & 4 deletions pabu/assets/ts/containers/TimeEntryList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,32 @@ import TimeEntryList, { DispatchProps, OwnProps, StateProps } from '../component
import { ExpandedTimeEntry, State, ThunkDispatcher, IssueStatus } from '../types';

function mapStateToProps(state: State, props: OwnProps): StateProps {
const {issues, timeEntries, users, tickingStat} = state;
const {issues, timeEntries, users, tickingStat, tags} = state;

let entries = [];
for (const id in timeEntries) {
const entry = timeEntries[id];
if (entry.projectId != props.id)
continue


const issue = (entry.issueId in issues) ? issues[entry.issueId] : null;

let exEntry: ExpandedTimeEntry = {
...entry,
issueStatus: (entry.issueId in issues) ? issues[entry.issueId].status : IssueStatus.TODO,
issueName: (entry.issueId in issues) ? issues[entry.issueId].name : '',
issueStatus: issue ? issue.status : IssueStatus.TODO,
issueName: issue ? issue.name : '',
userName: (entry.userId in users) ? users[entry.userId].name : '',
spentHours: (entry.end || new Date().getTime()/1000) - entry.start,
issueTags: issue ? Object.values(tags).filter(t => issue.tags.includes(t.id)).map(t => t.id) : [],
}
entries.push(exEntry)
}

return {
rows: entries,
tickingStat
tickingStat,
tags: Object.values(tags).filter(t => t.projectId == props.id),
}
}

Expand Down
2 changes: 2 additions & 0 deletions pabu/assets/ts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface ExpandedTimeEntry extends TimeEntry {
issueStatus: IssueStatus,
userName: string,
spentHours: number,
issueTags: Array<number>,
}

export interface User {
Expand Down Expand Up @@ -219,6 +220,7 @@ export class LocalStorageSchema {
[IssueStatus.IN_PROGRESS]: true,
[IssueStatus.DONE]: false,
};
issueTagFilter: Array<number> = [];
lastSeenChangelogVersion: string = 'v0.0.0';
}

Expand Down
19 changes: 10 additions & 9 deletions pabu/controllers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pabu.auth import get_user_id, is_logged_in
from pabu.db import Database
from pabu.models import Issue, Payment, Project, ProjectInvitationToken, TimeEntry, User, projects_users, Tag
from pabu.tools import (entry_stat_from_list, issue_to_dict, project_to_dict, project_token_to_dict, sqla_model_to_voluptuous,
from pabu.tools import (entry_stat_from_list, issue_to_dict, project_to_dict, project_token_to_dict, sqla_model_to_voluptuous, tag_to_dict,
time_entry_to_dict, user_to_dict, payment_to_dict, get_all_project_data as get_all_project_data_)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -140,14 +140,6 @@ def pre_update(new_data: dict, old_issue: Issue):
})
return {i.id: issue_to_dict(i) for i in issues}

# @jsonrpc_api.dispatcher.add_method
# def process_tags(tags): # pylint: disable=unused-variable
# with db.session_scope() as conn:
# issues = process_resources(tags, Tag, conn, {
# 'checker': lambda i: check_project(i.project_id, conn),
# })
# return {i.id: issue_to_dict(i) for i in issues}

@jsonrpc_api.dispatcher.add_method
def process_tags(issue_id, tags): # pylint: disable=unused-variable
with db.session_scope() as conn:
Expand All @@ -161,6 +153,15 @@ def process_tags(issue_id, tags): # pylint: disable=unused-variable
issue = conn.query(Issue).filter(Issue.id == issue_id).first()
issue.tags = new_tags + existing_tags

@jsonrpc_api.dispatcher.add_method
def get_tags(project_id: int): # pylint: disable=unused-variable
user_id = get_user_id()
with db.session_scope() as conn:
rows = conn.query(Tag).join(Project).join(projects_users).join(User).filter(User.id == user_id) \
.filter(Project.id == project_id).all()
return {r.id: tag_to_dict(r) for r in rows}


@jsonrpc_api.dispatcher.add_method
def get_issues(project_id: int): # pylint: disable=unused-variable
user_id = get_user_id()
Expand Down
Loading

0 comments on commit fe82f27

Please sign in to comment.