feat: project updated/created date + sorting on homepage

This commit is contained in:
wpetit 2023-08-31 21:56:07 -06:00
parent 18f43482e0
commit 3fc3d813d3
8 changed files with 181 additions and 66 deletions

View File

@ -13,7 +13,7 @@
"@types/json-merge-patch": "0.0.4", "@types/json-merge-patch": "0.0.4",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"bs58": "^4.0.1", "bs58": "^4.0.1",
"bulma": "^0.8.2", "bulma": "^0.9.4",
"bulma-switch": "^2.0.0", "bulma-switch": "^2.0.0",
"json-merge-patch": "^0.2.3", "json-merge-patch": "^0.2.3",
"react": "^16.13.1", "react": "^16.13.1",
@ -1232,9 +1232,9 @@
"integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug="
}, },
"node_modules/bulma": { "node_modules/bulma": {
"version": "0.8.2", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz", "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz",
"integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==" "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ=="
}, },
"node_modules/bulma-switch": { "node_modules/bulma-switch": {
"version": "2.0.0", "version": "2.0.0",
@ -9952,9 +9952,9 @@
"integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug="
}, },
"bulma": { "bulma": {
"version": "0.8.2", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz", "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz",
"integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==" "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ=="
}, },
"bulma-switch": { "bulma-switch": {
"version": "2.0.0", "version": "2.0.0",

View File

@ -8,7 +8,7 @@
"@types/json-merge-patch": "0.0.4", "@types/json-merge-patch": "0.0.4",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"bs58": "^4.0.1", "bs58": "^4.0.1",
"bulma": "^0.8.2", "bulma": "^0.9.4",
"bulma-switch": "^2.0.0", "bulma-switch": "^2.0.0",
"json-merge-patch": "^0.2.3", "json-merge-patch": "^0.2.3",
"react": "^16.13.1", "react": "^16.13.1",

View File

@ -83,7 +83,8 @@ export function handleAddTask(project: Project, action: AddTask): Project {
tasks: { tasks: {
...project.tasks, ...project.tasks,
[task.id]: task, [task.id]: task,
} },
updatedAt: new Date(),
}; };
} }
@ -102,7 +103,8 @@ export function handleRemoveTask(project: Project, action: RemoveTask): Project
delete tasks[action.id]; delete tasks[action.id];
return { return {
...project, ...project,
tasks tasks,
updatedAt: new Date(),
}; };
} }
@ -131,8 +133,9 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
[action.id]: { [action.id]: {
...project.tasks[action.id], ...project.tasks[action.id],
estimations: estimations, estimations: estimations,
} },
} },
updatedAt: new Date(),
}; };
} }
@ -150,7 +153,8 @@ export function updateProjectLabel(label: string): UpdateProjectLabel {
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project { export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
return { return {
...project, ...project,
label: action.label label: action.label,
updatedAt: new Date(),
}; };
} }
@ -174,7 +178,8 @@ export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel)
...project.tasks[action.id], ...project.tasks[action.id],
label: action.label, label: action.label,
} }
} },
updatedAt: new Date(),
}; };
} }
@ -195,7 +200,8 @@ export function handleUpdateParam(project: Project, action: UpdateParam): Projec
params: { params: {
...project.params, ...project.params,
[action.name]: action.value, [action.name]: action.value,
} },
updatedAt: new Date(),
}; };
} }
@ -222,7 +228,8 @@ export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTa
label: action.label label: action.label
}, },
} }
} },
updatedAt: new Date(),
}; };
} }
@ -249,7 +256,8 @@ export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTas
costPerTimeUnit: action.costPerTimeUnit costPerTimeUnit: action.costPerTimeUnit
}, },
} }
} },
updatedAt: new Date(),
}; };
} }
@ -273,7 +281,8 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory)
...project.params.taskCategories, ...project.params.taskCategories,
[taskCategory.id]: taskCategory, [taskCategory.id]: taskCategory,
} }
} },
updatedAt: new Date(),
}; };
} }
@ -295,7 +304,8 @@ export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCat
params: { params: {
...project.params, ...project.params,
taskCategories taskCategories
} },
updatedAt: new Date(),
}; };
} }
@ -313,5 +323,8 @@ export function handlePatchProject(project: Project, action: PatchProject): Proj
const p = diff(project, action.from); const p = diff(project, action.from);
console.log('patch to apply', p); console.log('patch to apply', p);
if (!p) return project; if (!p) return project;
return applyPatch(project, p) as Project; const patched: Project = applyPatch(project, p) as Project
if (typeof patched.createdAt === 'string') patched.createdAt = new Date(patched.createdAt)
if (typeof patched.updatedAt === 'string') patched.updatedAt = new Date(patched.updatedAt)
return patched;
} }

View File

@ -0,0 +1,23 @@
import { useCallback, useEffect, useState } from "react";
export enum Direction {
ASC = 1,
DESC = -1
}
export function useSort<T>(items: T[], key: string, direction: Direction = Direction.ASC): T[] {
const [ sorted, setSorted ] = useState(items);
useEffect(() => {
const sorted = [ ...items ]
sorted.sort((a: any, b: any) => {
if (a[key] > b[key]) return direction;
if (a[key] < b[key]) return -direction;
return 0
})
setSorted(sorted)
}, [key, items, direction])
return sorted
}

View File

@ -1,4 +1,4 @@
import { Task, TaskCategory, TaskID } from './task'; import { Task } from './task';
import { Params, defaults } from "./params"; import { Params, defaults } from "./params";
import { uuidV4 } from "../util/uuid"; import { uuidV4 } from "../util/uuid";
@ -10,6 +10,8 @@ export interface Project {
description: string description: string
tasks: Tasks tasks: Tasks
params: Params params: Params
createdAt: Date
updatedAt: Date
} }
export interface Tasks { export interface Tasks {
@ -25,5 +27,7 @@ export function newProject(id?: string): Project {
params: { params: {
...defaults ...defaults
}, },
createdAt: new Date(),
updatedAt: new Date(),
}; };
} }

View File

@ -1,58 +1,109 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent, MouseEvent, useCallback, useState } from "react";
import style from "./style.module.css"; import style from "./style.module.css";
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { base58UUID } from '../../util/uuid'; import { base58UUID } from '../../util/uuid';
import { useStoredProjectList } from "../../hooks/use-stored-project-list"; import { useStoredProjectList } from "../../hooks/use-stored-project-list";
import { formatDate } from "../../util/date";
import { Direction, useSort } from "../../hooks/useSort";
const Home: FunctionComponent = () => { const Home: FunctionComponent = () => {
const [ projects, refreshProjects ] = useStoredProjectList(); const [projects] = useStoredProjectList()
const history = useHistory(); const [sortingKey, setSortingKey] = useState('updatedAt')
const [sortingDirection, setSortingDirection] = useState<Direction>(Direction.DESC)
const sortedProjects = useSort(projects, sortingKey, sortingDirection)
const history = useHistory()
const openNewProject = () => { const openNewProject = () => {
const uuid = base58UUID(); const uuid = base58UUID()
history.push(`/p/${uuid}`); history.push(`/p/${uuid}`)
}; };
return ( const openProject = useCallback((evt: MouseEvent<HTMLTableRowElement>) => {
<div className={`container ${style.home}`}> const projectId = evt.currentTarget.dataset.projectId;
<div className="columns"> history.push(`/p/${projectId}`)
<div className="column"> }, [])
<div className="buttons is-right">
<button className="button is-primary" const sortBy = useCallback((evt: MouseEvent<HTMLTableCellElement>) => {
onClick={openNewProject}> const key = evt.currentTarget.dataset.sortKey
<strong>+</strong>&nbsp;&nbsp;Nouveau projet if (!key) return
</button>
</div> if (sortingKey !== key) {
<div className="panel"> setSortingKey(key)
<p className="panel-heading"> return
Mes projets }
</p>
{/* <div className="panel-block"> setSortingDirection(sortingDirection => -sortingDirection)
<p className="control has-icons-left">
<input className="input" type="text" placeholder="Search" /> }, [sortingKey, sortingDirection])
<span className="icon is-left">🔍</span>
</p> return (
</div> */} <div className={`container ${style.home}`}>
{ <div className="columns">
projects.map(p => ( <div className="column">
<a key={`project-${p.id}`} className="panel-block" href={`/p/${p.id}`}> <div className="buttons is-right">
<span className="panel-icon">🗒</span> <button className="button is-primary is-medium"
{ p.label ? p.label : "Projet sans nom" } onClick={openNewProject}>
</a> <strong>+</strong>&nbsp;&nbsp;Nouveau projet
)) </button>
} </div>
{ <div className="box">
projects.length === 0 ? <div className="table-container">
<p className="panel-block"> <table className="table is-fullwidth is-hoverable">
<div className={style.noProjects}>Aucun project pour l'instant.</div> <thead>
</p> : <tr className="is-size-5">
null <TableHeader onClick={sortBy} label="Projet" isCurrentSortingKey={sortingKey === 'label'} sortingDirection={sortingDirection} sortingKey="label" />
} <TableHeader onClick={sortBy} label="Mis à jour le" isCurrentSortingKey={sortingKey === 'updatedAt'} sortingDirection={sortingDirection} sortingKey="updatedAt" />
</div> </tr>
</div> </thead>
<tbody>
{
sortedProjects.map(p => (
<tr
key={`project-${p.id}`}
data-project-id={p.id}
onClick={openProject}
className="is-clickable">
<td>
<span className="mr-3">🗒</span>
{p.label ? p.label : "Projet sans nom"}
</td>
<td>
{p.updatedAt ? formatDate(p.updatedAt) : "--"}
</td>
</tr>
))
}
{
projects.length === 0 ?
<tr className={style.noProjects}>
<td colSpan={2}>
Aucun project pour l'instant.
</td>
</tr> :
null
}
</tbody>
</table>
</div> </div>
</div>
</div> </div>
); </div>
</div>
);
}; };
interface TableHeaderProps {
onClick?: React.MouseEventHandler<HTMLTableCellElement>
sortingKey: string
isCurrentSortingKey: boolean
sortingDirection: Direction
label: string
}
const TableHeader: FunctionComponent<TableHeaderProps> = ({ onClick, isCurrentSortingKey, sortingKey, label, sortingDirection }) => {
return (
<th className="is-clickable" onClick={onClick} data-sort-key={sortingKey}>{label}<span className={`ml-1 ${!isCurrentSortingKey ? 'is-hidden' : ''}`}>{sortingDirection === Direction.ASC ? '↑' : '↓'}</span></th>
)
}
export default Home; export default Home;

20
client/src/util/date.ts Normal file
View File

@ -0,0 +1,20 @@
export function asDate (d: Date|string): Date {
if (typeof d === 'string') return new Date(d)
if (d instanceof Date) return d
throw new Error(`Unexpected date value '${JSON.stringify(d)}' !`)
}
const intl = Intl.DateTimeFormat(navigator.language, {
weekday: 'long',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
export function formatDate (d: Date|string): string {
d = asDate(d)
return intl.format(d)
}

View File

@ -1,5 +1,7 @@
package model package model
import "time"
type ProjectID string type ProjectID string
type ProjectEntry struct { type ProjectEntry struct {
@ -14,6 +16,8 @@ type Project struct {
Description string `json:"description"` Description string `json:"description"`
Tasks map[TaskID]Task `json:"tasks"` Tasks map[TaskID]Task `json:"tasks"`
Params Params `json:"params"` Params Params `json:"params"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type Params struct { type Params struct {