react-repos 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -9
- package/repos/task-common/README.md +63 -0
- package/repos/task-common/backend/.env.example +3 -0
- package/repos/task-common/backend/app.js +16 -0
- package/repos/task-common/backend/config/db.js +11 -0
- package/repos/task-common/backend/controllers/itemController.js +51 -0
- package/repos/task-common/backend/data/items.json +5 -0
- package/repos/task-common/backend/middleware/errorHandler.js +7 -0
- package/repos/task-common/backend/middleware/notFound.js +3 -0
- package/repos/task-common/backend/models/Item.js +16 -0
- package/repos/task-common/backend/package.json +22 -0
- package/repos/task-common/backend/pnpm-lock.yaml +970 -0
- package/repos/task-common/backend/routes/itemRoutes.js +9 -0
- package/repos/task-common/backend/scripts/seed.js +22 -0
- package/repos/task-common/backend/server.js +12 -0
- package/repos/task-common/backend/test/buildQuery.test.js +16 -0
- package/repos/task-common/backend/utils/buildQuery.js +15 -0
- package/repos/task-common/frontend/.env.example +2 -0
- package/repos/task-common/frontend/package.json +27 -0
- package/repos/task-common/frontend/pnpm-lock.yaml +12209 -0
- package/repos/task-common/frontend/public/index.html +14 -0
- package/repos/task-common/frontend/src/App.js +28 -0
- package/repos/task-common/frontend/src/App.test.js +14 -0
- package/repos/task-common/frontend/src/api/httpClient.js +14 -0
- package/repos/task-common/frontend/src/api/itemService.js +9 -0
- package/repos/task-common/frontend/src/components/common/Badge.js +12 -0
- package/repos/task-common/frontend/src/components/common/ConfirmDialog.js +8 -0
- package/repos/task-common/frontend/src/components/common/DataTable.js +9 -0
- package/repos/task-common/frontend/src/components/common/EmptyState.js +6 -0
- package/repos/task-common/frontend/src/components/common/ErrorAlert.js +6 -0
- package/repos/task-common/frontend/src/components/common/FilterBar.js +8 -0
- package/repos/task-common/frontend/src/components/common/LoadingState.js +5 -0
- package/repos/task-common/frontend/src/components/common/Modal.js +8 -0
- package/repos/task-common/frontend/src/components/common/PageHeader.js +8 -0
- package/repos/task-common/frontend/src/components/common/Pagination.js +10 -0
- package/repos/task-common/frontend/src/components/common/ProgressBar.js +9 -0
- package/repos/task-common/frontend/src/components/common/StatCard.js +8 -0
- package/repos/task-common/frontend/src/components/items/ItemCard.js +10 -0
- package/repos/task-common/frontend/src/components/items/ItemForm.js +18 -0
- package/repos/task-common/frontend/src/components/layout/AppLayout.js +11 -0
- package/repos/task-common/frontend/src/components/layout/Sidebar.js +23 -0
- package/repos/task-common/frontend/src/components/layout/Topbar.js +13 -0
- package/repos/task-common/frontend/src/config/theme.js +11 -0
- package/repos/task-common/frontend/src/context/ItemContext.js +9 -0
- package/repos/task-common/frontend/src/hooks/useItems.js +30 -0
- package/repos/task-common/frontend/src/hooks/usePagination.js +10 -0
- package/repos/task-common/frontend/src/index.js +11 -0
- package/repos/task-common/frontend/src/pages/DashboardPage.js +30 -0
- package/repos/task-common/frontend/src/pages/ItemsPage.js +36 -0
- package/repos/task-common/frontend/src/pages/ProfilePage.js +9 -0
- package/repos/task-common/frontend/src/utils/date.js +4 -0
- package/repos/task1/README.md +34 -0
- package/repos/task1/backend/.env.example +3 -0
- package/repos/task1/backend/app.js +17 -0
- package/repos/task1/backend/config/db.js +12 -0
- package/repos/task1/backend/controllers/taskController.js +68 -0
- package/repos/task1/backend/data/taskflow-data.json +14 -0
- package/repos/task1/backend/models/Task.js +21 -0
- package/repos/task1/backend/package-lock.json +1462 -0
- package/repos/task1/backend/package.json +25 -0
- package/repos/task1/backend/routes/taskRoutes.js +9 -0
- package/repos/task1/backend/scripts/seed.js +22 -0
- package/repos/task1/backend/server.js +12 -0
- package/repos/task1/frontend/README.md +70 -0
- package/repos/task1/frontend/package-lock.json +17515 -0
- package/repos/task1/frontend/package.json +43 -0
- package/repos/task1/frontend/public/favicon.ico +0 -0
- package/repos/task1/frontend/public/index.html +43 -0
- package/repos/task1/frontend/public/logo192.png +0 -0
- package/repos/task1/frontend/public/logo512.png +0 -0
- package/repos/task1/frontend/public/manifest.json +25 -0
- package/repos/task1/frontend/public/robots.txt +3 -0
- package/repos/task1/frontend/src/App.js +44 -0
- package/repos/task1/frontend/src/App.test.js +10 -0
- package/repos/task1/frontend/src/api/taskService.js +9 -0
- package/repos/task1/frontend/src/components/Dashboard.js +28 -0
- package/repos/task1/frontend/src/components/DashboardTable.js +29 -0
- package/repos/task1/frontend/src/components/Layout.js +15 -0
- package/repos/task1/frontend/src/components/Pagination.js +19 -0
- package/repos/task1/frontend/src/components/PriorityPill.js +4 -0
- package/repos/task1/frontend/src/components/Profile.js +4 -0
- package/repos/task1/frontend/src/components/Sidebar.js +26 -0
- package/repos/task1/frontend/src/components/StatCard.js +6 -0
- package/repos/task1/frontend/src/components/StatusPill.js +4 -0
- package/repos/task1/frontend/src/components/TaskCard.js +22 -0
- package/repos/task1/frontend/src/components/TaskFilters.js +11 -0
- package/repos/task1/frontend/src/components/TaskFormModal.js +35 -0
- package/repos/task1/frontend/src/components/Tasks.js +42 -0
- package/repos/task1/frontend/src/components/Topbar.js +18 -0
- package/repos/task1/frontend/src/components/theme.js +8 -0
- package/repos/task1/frontend/src/index.js +14 -0
- package/repos/task1/screenshots/add-task-form.jpg +0 -0
- package/repos/task1/screenshots/dashboard.jpg +0 -0
- package/repos/task1/screenshots/tasks.jpg +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="theme-color" content="#1E3A8A" />
|
|
7
|
+
<meta name="description" content="Reusable MERN application starter" />
|
|
8
|
+
<title>MERN Common Starter</title>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<noscript>JavaScript is required to run this application.</noscript>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Declares routes and provides shared CRUD state to the application shell.
|
|
2
|
+
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import AppLayout from './components/layout/AppLayout';
|
|
4
|
+
import DashboardPage from './pages/DashboardPage';
|
|
5
|
+
import ItemsPage from './pages/ItemsPage';
|
|
6
|
+
import ProfilePage from './pages/ProfilePage';
|
|
7
|
+
import useItems from './hooks/useItems';
|
|
8
|
+
import { ItemContext } from './context/ItemContext';
|
|
9
|
+
|
|
10
|
+
function App() {
|
|
11
|
+
const itemState = useItems();
|
|
12
|
+
return (
|
|
13
|
+
<ItemContext.Provider value={itemState}>
|
|
14
|
+
<BrowserRouter>
|
|
15
|
+
<Routes>
|
|
16
|
+
<Route element={<AppLayout />}>
|
|
17
|
+
<Route path="/dashboard" element={<DashboardPage />} />
|
|
18
|
+
<Route path="/items" element={<ItemsPage />} />
|
|
19
|
+
<Route path="/profile" element={<ProfilePage />} />
|
|
20
|
+
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
21
|
+
</Route>
|
|
22
|
+
</Routes>
|
|
23
|
+
</BrowserRouter>
|
|
24
|
+
</ItemContext.Provider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default App;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Verifies that the application shell and default dashboard route render.
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import App from './App';
|
|
5
|
+
|
|
6
|
+
jest.mock('./api/itemService', () => ({
|
|
7
|
+
listItems: jest.fn().mockResolvedValue([]),
|
|
8
|
+
createItem: jest.fn(), updateItem: jest.fn(), patchItem: jest.fn(), deleteItem: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
test('renders the reusable dashboard', async () => {
|
|
12
|
+
render(<App />);
|
|
13
|
+
expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
|
|
14
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Configures the shared Axios client and a consistent response error message.
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
|
|
4
|
+
const httpClient = axios.create({
|
|
5
|
+
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:5002/api',
|
|
6
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
httpClient.interceptors.response.use(
|
|
10
|
+
(response) => response,
|
|
11
|
+
(error) => Promise.reject(new Error(error.response?.data?.message || 'Request failed'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export default httpClient;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Exposes domain-specific CRUD functions while hiding HTTP details from pages.
|
|
2
|
+
import httpClient from './httpClient';
|
|
3
|
+
|
|
4
|
+
export const listItems = async (params = {}) => (await httpClient.get('/items', { params })).data;
|
|
5
|
+
export const getItem = async (id) => (await httpClient.get(`/items/${id}`)).data;
|
|
6
|
+
export const createItem = async (values) => (await httpClient.post('/items', values)).data;
|
|
7
|
+
export const updateItem = async (id, values) => (await httpClient.put(`/items/${id}`, values)).data;
|
|
8
|
+
export const patchItem = async (id, values) => (await httpClient.patch(`/items/${id}`, values)).data;
|
|
9
|
+
export const deleteItem = async (id) => (await httpClient.delete(`/items/${id}`)).data;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Displays priority or status values with a reusable color map.
|
|
2
|
+
const palette = {
|
|
3
|
+
High: ['#DC2626', '#FEE2E2'], Medium: ['#D97706', '#FEF3C7'], Low: ['#2563EB', '#DBEAFE'],
|
|
4
|
+
'To Do': ['#D97706', '#FEF3C7'], 'In Progress': ['#2563EB', '#DBEAFE'], Review: ['#7C3AED', '#EDE9FE'], Done: ['#16A34A', '#DCFCE7'],
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function Badge({ value }) {
|
|
8
|
+
const [color, background] = palette[value] || ['#374151', '#F3F4F6'];
|
|
9
|
+
return <span style={{ color, background, padding: '4px 12px', borderRadius: 9999, fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap' }}>{value}</span>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default Badge;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Asks for explicit confirmation before a destructive record action.
|
|
2
|
+
import Modal from './Modal';
|
|
3
|
+
|
|
4
|
+
function ConfirmDialog({ title = 'Confirm action', message, onConfirm, onClose }) {
|
|
5
|
+
return <Modal title={title} onClose={onClose} footer={<><button className="btn btn-light" onClick={onClose}>Cancel</button><button className="btn btn-danger" onClick={onConfirm}>Confirm</button></>}><p className="mb-0">{message}</p></Modal>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default ConfirmDialog;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Renders records from configurable column definitions and optional row actions.
|
|
2
|
+
import EmptyState from './EmptyState';
|
|
3
|
+
|
|
4
|
+
function DataTable({ columns, records, rowKey = '_id', actions }) {
|
|
5
|
+
if (!records.length) return <EmptyState title="No records found" />;
|
|
6
|
+
return <div className="table-responsive"><table className="table align-middle"><thead><tr>{columns.map((column) => <th key={column.key}>{column.label}</th>)}{actions && <th>Actions</th>}</tr></thead><tbody>{records.map((record) => <tr key={record[rowKey]}>{columns.map((column) => <td key={column.key}>{column.render ? column.render(record[column.key], record) : record[column.key]}</td>)}{actions && <td>{actions(record)}</td>}</tr>)}</tbody></table></div>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default DataTable;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Renders a compact reusable empty-state message.
|
|
2
|
+
import { colors } from '../../config/theme';
|
|
3
|
+
function EmptyState({ title, message = 'Try changing the filters or add a new record.' }) {
|
|
4
|
+
return <div className="text-center py-5"><i className="bi bi-inbox fs-2" style={{ color: colors.muted }} /><h3 className="fs-6 mt-2">{title}</h3><p className="small mb-0" style={{ color: colors.muted }}>{message}</p></div>;
|
|
5
|
+
}
|
|
6
|
+
export default EmptyState;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Displays recoverable API errors and an optional retry control.
|
|
2
|
+
function ErrorAlert({ message, onRetry }) {
|
|
3
|
+
if (!message) return null;
|
|
4
|
+
return <div className="alert alert-danger d-flex justify-content-between align-items-center" role="alert"><span>{message}</span>{onRetry && <button className="btn btn-sm btn-outline-danger" onClick={onRetry}>Retry</button>}</div>;
|
|
5
|
+
}
|
|
6
|
+
export default ErrorAlert;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Builds a search input and any number of select filters from configuration.
|
|
2
|
+
import { controlStyle } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function FilterBar({ search, onSearchChange, filters = [] }) {
|
|
5
|
+
return <div className="row g-3 mb-4"><div className="col"><input className="form-control" style={controlStyle} value={search} onChange={(event) => onSearchChange(event.target.value)} placeholder="Search by name..." /></div>{filters.map((filter) => <div className="col-auto" key={filter.key}><select className="form-select" style={{ ...controlStyle, minWidth: 160 }} value={filter.value} onChange={(event) => filter.onChange(event.target.value)}><option value="">{filter.placeholder}</option>{filter.options.map((option) => <option key={option}>{option}</option>)}</select></div>)}</div>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default FilterBar;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Shows a consistent loading indicator while remote data is pending.
|
|
2
|
+
function LoadingState({ label = 'Loading...' }) {
|
|
3
|
+
return <div className="d-flex justify-content-center align-items-center gap-2 py-5"><span className="spinner-border spinner-border-sm" aria-hidden="true" /><span>{label}</span></div>;
|
|
4
|
+
}
|
|
5
|
+
export default LoadingState;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Supplies a reusable accessible modal shell for forms and confirmations.
|
|
2
|
+
import { colors } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function Modal({ title, children, footer, onClose }) {
|
|
5
|
+
return <div className="modal d-block" role="dialog" aria-modal="true" style={{ background: 'rgba(17,24,39,.45)' }}><div className="modal-dialog modal-dialog-centered"><div className="modal-content border-0" style={{ borderRadius: 12 }}><div className="modal-header" style={{ borderColor: colors.border }}><h2 className="modal-title fs-5">{title}</h2><button className="btn-close" onClick={onClose} aria-label="Close" /></div><div className="modal-body p-4">{children}</div>{footer && <div className="modal-footer" style={{ borderColor: colors.border }}>{footer}</div>}</div></div></div>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default Modal;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Standardizes page titles, helper text, and optional primary actions.
|
|
2
|
+
import { colors } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function PageHeader({ title, subtitle, action }) {
|
|
5
|
+
return <div className="d-flex justify-content-between align-items-start mb-4"><div><h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 5 }}>{title}</h1>{subtitle && <p style={{ color: colors.muted, fontSize: 13, margin: 0 }}>{subtitle}</p>}</div>{action}</div>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default PageHeader;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Provides reusable previous, numbered, and next page controls.
|
|
2
|
+
import { colors } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function Pagination({ page, pageCount, total, pageSize, onChange }) {
|
|
5
|
+
const start = total ? (page - 1) * pageSize + 1 : 0;
|
|
6
|
+
const end = Math.min(page * pageSize, total);
|
|
7
|
+
return <div className="d-flex justify-content-between align-items-center mt-3"><small style={{ color: colors.muted }}>Showing {start} to {end} of {total}</small><div className="d-flex gap-1"><button className="btn btn-sm" disabled={page === 1} onClick={() => onChange(page - 1)}>Previous</button>{Array.from({ length: pageCount }, (_, index) => index + 1).map((number) => <button key={number} className={`btn btn-sm ${number === page ? 'btn-primary' : 'btn-light'}`} onClick={() => onChange(number)}>{number}</button>)}<button className="btn btn-sm" disabled={page === pageCount} onClick={() => onChange(page + 1)}>Next</button></div></div>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default Pagination;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Renders a labelled progress indicator with clamped percentage values.
|
|
2
|
+
import { colors } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function ProgressBar({ value = 0, showLabel = true }) {
|
|
5
|
+
const safeValue = Math.min(100, Math.max(0, Number(value) || 0));
|
|
6
|
+
return <div className="d-flex align-items-center gap-2"><div className="progress flex-grow-1" style={{ height: 8, background: colors.border, borderRadius: 9999 }}><div className="progress-bar" style={{ width: `${safeValue}%`, background: colors.primary, borderRadius: 9999 }} /></div>{showLabel && <small>{safeValue}%</small>}</div>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Presents a reusable dashboard metric card with a colored accent.
|
|
2
|
+
import { colors, surfaceStyle } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function StatCard({ label, value, helper, accent = colors.primary }) {
|
|
5
|
+
return <div style={{ ...surfaceStyle, padding: 24, borderLeft: `3px solid ${accent}` }}><div style={{ color: colors.muted, fontSize: 13 }}>{label}</div><div style={{ color: accent, fontSize: 32, fontWeight: 700 }}>{value}</div>{helper && <div style={{ color: colors.muted, fontSize: 12 }}>{helper}</div>}</div>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default StatCard;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Composes common badges and progress into a reusable mobile-friendly item card.
|
|
2
|
+
import Badge from '../common/Badge';
|
|
3
|
+
import ProgressBar from '../common/ProgressBar';
|
|
4
|
+
import { colors, surfaceStyle } from '../../config/theme';
|
|
5
|
+
|
|
6
|
+
function ItemCard({ item, onEdit, onDelete }) {
|
|
7
|
+
return <article style={{ ...surfaceStyle, padding: 20, marginBottom: 16 }}><div className="d-flex justify-content-between"><div><h3 className="fs-6 mb-1">{item.name}</h3><p className="small mb-3" style={{ color: colors.muted }}>{item.description}</p></div><div className="dropdown"><button className="btn border-0" data-bs-toggle="dropdown" aria-label={`Actions for ${item.name}`}><i className="bi bi-three-dots-vertical" /></button><ul className="dropdown-menu dropdown-menu-end"><li><button className="dropdown-item" onClick={() => onEdit(item)}>Edit</button></li><li><button className="dropdown-item text-danger" onClick={() => onDelete(item)}>Delete</button></li></ul></div></div><div className="row align-items-center"><div className="col"><Badge value={item.priority} /></div><div className="col"><Badge value={item.status} /></div><div className="col-5"><ProgressBar value={item.progress} /></div></div></article>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default ItemCard;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Implements a reusable controlled create/edit form for the starter entity.
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import Modal from '../common/Modal';
|
|
4
|
+
import { controlStyle, primaryButtonStyle } from '../../config/theme';
|
|
5
|
+
|
|
6
|
+
const emptyItem = { name: '', description: '', priority: 'Medium', status: 'To Do', dueDate: '', progress: 0 };
|
|
7
|
+
|
|
8
|
+
function ItemForm({ initialValue, onSubmit, onClose }) {
|
|
9
|
+
const [values, setValues] = useState(emptyItem);
|
|
10
|
+
const [saving, setSaving] = useState(false);
|
|
11
|
+
useEffect(() => setValues(initialValue ? { ...initialValue, dueDate: initialValue.dueDate.slice(0, 10) } : emptyItem), [initialValue]);
|
|
12
|
+
const change = ({ target }) => setValues((current) => ({ ...current, [target.name]: target.name === 'progress' ? Number(target.value) : target.value }));
|
|
13
|
+
const submit = async (event) => { event.preventDefault(); setSaving(true); try { await onSubmit(values); onClose(); } finally { setSaving(false); } };
|
|
14
|
+
|
|
15
|
+
return <Modal title={initialValue ? 'Edit Item' : 'Add Item'} onClose={onClose} footer={<><button type="button" className="btn btn-light" onClick={onClose}>Cancel</button><button type="submit" form="item-form" className="btn btn-primary" style={primaryButtonStyle} disabled={saving}>{saving ? 'Saving...' : 'Save'}</button></>}><form id="item-form" onSubmit={submit}><label className="form-label">Name</label><input className="form-control mb-3" style={controlStyle} name="name" value={values.name} onChange={change} required /><label className="form-label">Description</label><input className="form-control mb-3" style={controlStyle} name="description" value={values.description} onChange={change} /><div className="row g-3 mb-3"><div className="col"><label className="form-label">Priority</label><select className="form-select" style={controlStyle} name="priority" value={values.priority} onChange={change}><option>High</option><option>Medium</option><option>Low</option></select></div><div className="col"><label className="form-label">Status</label><select className="form-select" style={controlStyle} name="status" value={values.status} onChange={change}><option>To Do</option><option>In Progress</option><option>Review</option><option>Done</option></select></div></div><div className="row g-3"><div className="col"><label className="form-label">Due date</label><input type="date" className="form-control" style={controlStyle} name="dueDate" value={values.dueDate} onChange={change} required /></div><div className="col"><label className="form-label">Progress</label><input type="number" min="0" max="100" className="form-control" style={controlStyle} name="progress" value={values.progress} onChange={change} /></div></div></form></Modal>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default ItemForm;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Combines the reusable navigation shell with nested React Router pages.
|
|
2
|
+
import { Outlet } from 'react-router-dom';
|
|
3
|
+
import Sidebar from './Sidebar';
|
|
4
|
+
import Topbar from './Topbar';
|
|
5
|
+
import { colors } from '../../config/theme';
|
|
6
|
+
|
|
7
|
+
function AppLayout() {
|
|
8
|
+
return <div style={{ minHeight: '100vh', background: colors.page, color: colors.text, fontFamily: 'Inter, system-ui, sans-serif' }}><Sidebar /><div style={{ marginLeft: 240 }}><Topbar /><main style={{ padding: 32 }}><Outlet /></main></div></div>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default AppLayout;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Renders configurable fixed navigation with automatic active-route styling.
|
|
2
|
+
import { NavLink } from 'react-router-dom';
|
|
3
|
+
import { appConfig, colors } from '../../config/theme';
|
|
4
|
+
|
|
5
|
+
const defaultLinks = [
|
|
6
|
+
{ to: '/dashboard', label: 'Dashboard', icon: 'bi-grid' },
|
|
7
|
+
{ to: '/items', label: 'Items', icon: 'bi-list-check' },
|
|
8
|
+
{ to: '/profile', label: 'Profile', icon: 'bi-person' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function Sidebar({ links = defaultLinks }) {
|
|
12
|
+
return (
|
|
13
|
+
<aside style={{ position: 'fixed', inset: '0 auto 0 0', width: 240, padding: '30px 20px', color: '#fff', background: colors.sidebar }}>
|
|
14
|
+
<div style={{ margin: '0 12px 28px', fontSize: 22, fontWeight: 700 }}>{appConfig.name}</div>
|
|
15
|
+
<nav className="d-grid gap-2">
|
|
16
|
+
{links.map((link) => <NavLink key={link.to} to={link.to} style={({ isActive }) => ({ display: 'flex', gap: 12, alignItems: 'center', padding: '12px 14px', borderRadius: 6, color: '#fff', textDecoration: 'none', background: isActive ? colors.active : 'transparent' })}><i className={`bi ${link.icon}`} />{link.label}</NavLink>)}
|
|
17
|
+
</nav>
|
|
18
|
+
<small style={{ position: 'absolute', bottom: 24, left: 32 }}>Reusable MERN starter</small>
|
|
19
|
+
</aside>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default Sidebar;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Provides a reusable application header with menu, search, and user slots.
|
|
2
|
+
import { appConfig, colors } from '../../config/theme';
|
|
3
|
+
|
|
4
|
+
function Topbar({ searchPlaceholder = 'Search...' }) {
|
|
5
|
+
return (
|
|
6
|
+
<header style={{ height: 72, padding: '0 32px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: colors.surface, borderBottom: `1px solid ${colors.border}` }}>
|
|
7
|
+
<div className="d-flex align-items-center gap-4"><i className="bi bi-list fs-4" /><div className="position-relative"><i className="bi bi-search position-absolute" style={{ top: 9, left: 12, color: colors.muted }} /><input className="form-control border-0" style={{ width: 300, paddingLeft: 36 }} placeholder={searchPlaceholder} aria-label="Global search" /></div></div>
|
|
8
|
+
<span>{appConfig.userName} <i className="bi bi-chevron-down ms-1 small" /></span>
|
|
9
|
+
</header>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default Topbar;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Holds application branding and inline style tokens in one replaceable module.
|
|
2
|
+
export const appConfig = { name: 'COMMON', userName: 'Jane Doe' };
|
|
3
|
+
|
|
4
|
+
export const colors = {
|
|
5
|
+
sidebar: '#1E3A8A', active: '#2C4FC7', primary: '#2563EB', primaryHover: '#1D4ED8',
|
|
6
|
+
page: '#F9FAFB', surface: '#FFFFFF', border: '#E5E7EB', text: '#111827', muted: '#6B7280',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const surfaceStyle = { background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12 };
|
|
10
|
+
export const controlStyle = { height: 40, borderRadius: 8, borderColor: '#D1D5DB', fontSize: 14 };
|
|
11
|
+
export const primaryButtonStyle = { background: colors.primary, borderColor: colors.primary, borderRadius: 8, padding: '10px 20px' };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shares item CRUD state across routed pages without prop drilling.
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
|
|
4
|
+
export const ItemContext = createContext(null);
|
|
5
|
+
export const useItemContext = () => {
|
|
6
|
+
const value = useContext(ItemContext);
|
|
7
|
+
if (!value) throw new Error('useItemContext must be used inside ItemContext.Provider');
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Provides reusable item loading and CRUD state for any page or component.
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import * as service from '../api/itemService';
|
|
4
|
+
|
|
5
|
+
export default function useItems() {
|
|
6
|
+
const [items, setItems] = useState([]);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState('');
|
|
9
|
+
|
|
10
|
+
const refresh = useCallback(async () => {
|
|
11
|
+
try { setError(''); setItems((await service.listItems()) || []); }
|
|
12
|
+
catch (requestError) { setError(requestError.message); }
|
|
13
|
+
finally { setLoading(false); }
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
17
|
+
|
|
18
|
+
const runAndRefresh = async (operation) => {
|
|
19
|
+
try { setError(''); await operation(); await refresh(); }
|
|
20
|
+
catch (requestError) { setError(requestError.message); throw requestError; }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
items, loading, error, refresh,
|
|
25
|
+
create: (values) => runAndRefresh(() => service.createItem(values)),
|
|
26
|
+
update: (id, values) => runAndRefresh(() => service.updateItem(id, values)),
|
|
27
|
+
patch: (id, values) => runAndRefresh(() => service.patchItem(id, values)),
|
|
28
|
+
remove: (id) => runAndRefresh(() => service.deleteItem(id)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Encapsulates client-side pagination calculations and page correction.
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export default function usePagination(records = [], pageSize = 5) {
|
|
5
|
+
const [page, setPage] = useState(1);
|
|
6
|
+
const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
|
|
7
|
+
useEffect(() => { if (page > pageCount) setPage(pageCount); }, [page, pageCount]);
|
|
8
|
+
const visible = useMemo(() => records.slice((page - 1) * pageSize, page * pageSize), [records, page, pageSize]);
|
|
9
|
+
return { page, setPage, pageCount, visible, pageSize };
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Boots React and loads Bootstrap as the only global stylesheet.
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import ReactDOM from 'react-dom/client';
|
|
4
|
+
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
5
|
+
import 'bootstrap-icons/font/bootstrap-icons.css';
|
|
6
|
+
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
|
7
|
+
import App from './App';
|
|
8
|
+
|
|
9
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
10
|
+
<React.StrictMode><App /></React.StrictMode>
|
|
11
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Demonstrates reusable metric cards, table columns, states, and pagination.
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useItemContext } from '../context/ItemContext';
|
|
4
|
+
import usePagination from '../hooks/usePagination';
|
|
5
|
+
import PageHeader from '../components/common/PageHeader';
|
|
6
|
+
import StatCard from '../components/common/StatCard';
|
|
7
|
+
import DataTable from '../components/common/DataTable';
|
|
8
|
+
import Pagination from '../components/common/Pagination';
|
|
9
|
+
import Badge from '../components/common/Badge';
|
|
10
|
+
import LoadingState from '../components/common/LoadingState';
|
|
11
|
+
import ErrorAlert from '../components/common/ErrorAlert';
|
|
12
|
+
import { surfaceStyle } from '../config/theme';
|
|
13
|
+
import { formatDate } from '../utils/date';
|
|
14
|
+
|
|
15
|
+
function DashboardPage() {
|
|
16
|
+
const { items, loading, error, refresh } = useItemContext();
|
|
17
|
+
const { page, setPage, pageCount, visible, pageSize } = usePagination(items);
|
|
18
|
+
const done = items.filter((item) => item.status === 'Done').length;
|
|
19
|
+
const active = items.filter((item) => item.status === 'In Progress').length;
|
|
20
|
+
const columns = useMemo(() => [
|
|
21
|
+
{ key: 'name', label: 'Name' },
|
|
22
|
+
{ key: 'priority', label: 'Priority', render: (value) => <Badge value={value} /> },
|
|
23
|
+
{ key: 'status', label: 'Status', render: (value) => <Badge value={value} /> },
|
|
24
|
+
{ key: 'dueDate', label: 'Due date', render: formatDate },
|
|
25
|
+
], []);
|
|
26
|
+
|
|
27
|
+
return <><PageHeader title="Dashboard" subtitle="Reusable components driven by live API data." /><ErrorAlert message={error} onRetry={refresh} /><div className="row g-4 mb-4"><div className="col"><StatCard label="Total" value={items.length} helper="All records" /></div><div className="col"><StatCard label="In Progress" value={active} helper="Active records" accent="#D97706" /></div><div className="col"><StatCard label="Done" value={done} helper="Completed records" accent="#16A34A" /></div></div><section style={{ ...surfaceStyle, padding: 24 }}><h2 className="fs-5 mb-4">Recent Items</h2>{loading ? <LoadingState /> : <DataTable columns={columns} records={visible} />}<Pagination page={page} pageCount={pageCount} total={items.length} pageSize={pageSize} onChange={setPage} /></section></>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default DashboardPage;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Demonstrates reusable search, filters, cards, forms, confirmation, and CRUD hooks.
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { useItemContext } from '../context/ItemContext';
|
|
4
|
+
import usePagination from '../hooks/usePagination';
|
|
5
|
+
import PageHeader from '../components/common/PageHeader';
|
|
6
|
+
import FilterBar from '../components/common/FilterBar';
|
|
7
|
+
import Pagination from '../components/common/Pagination';
|
|
8
|
+
import LoadingState from '../components/common/LoadingState';
|
|
9
|
+
import ErrorAlert from '../components/common/ErrorAlert';
|
|
10
|
+
import EmptyState from '../components/common/EmptyState';
|
|
11
|
+
import ConfirmDialog from '../components/common/ConfirmDialog';
|
|
12
|
+
import ItemForm from '../components/items/ItemForm';
|
|
13
|
+
import ItemCard from '../components/items/ItemCard';
|
|
14
|
+
import { primaryButtonStyle } from '../config/theme';
|
|
15
|
+
|
|
16
|
+
function ItemsPage() {
|
|
17
|
+
const { items, loading, error, refresh, create, update, remove } = useItemContext();
|
|
18
|
+
const [search, setSearch] = useState('');
|
|
19
|
+
const [priority, setPriority] = useState('');
|
|
20
|
+
const [status, setStatus] = useState('');
|
|
21
|
+
const [editing, setEditing] = useState(null);
|
|
22
|
+
const [deleting, setDeleting] = useState(null);
|
|
23
|
+
const filtered = useMemo(() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()) && (!priority || item.priority === priority) && (!status || item.status === status)), [items, search, priority, status]);
|
|
24
|
+
const { page, setPage, pageCount, visible, pageSize } = usePagination(filtered);
|
|
25
|
+
const save = (values) => editing === 'new' ? create(values) : update(editing._id, values);
|
|
26
|
+
const confirmDelete = async () => { await remove(deleting._id); setDeleting(null); };
|
|
27
|
+
|
|
28
|
+
const filters = [
|
|
29
|
+
{ key: 'priority', value: priority, onChange: setPriority, placeholder: 'All priorities', options: ['High', 'Medium', 'Low'] },
|
|
30
|
+
{ key: 'status', value: status, onChange: setStatus, placeholder: 'All statuses', options: ['To Do', 'In Progress', 'Review', 'Done'] },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
return <><PageHeader title="Items" subtitle="Use this page as the template for any CRUD entity." action={<button className="btn btn-primary" style={primaryButtonStyle} onClick={() => setEditing('new')}>Add Item</button>} /><ErrorAlert message={error} onRetry={refresh} /><FilterBar search={search} onSearchChange={setSearch} filters={filters} />{loading ? <LoadingState /> : visible.length ? visible.map((item) => <ItemCard key={item._id} item={item} onEdit={setEditing} onDelete={setDeleting} />) : <EmptyState title="No items found" />}<Pagination page={page} pageCount={pageCount} total={filtered.length} pageSize={pageSize} onChange={setPage} />{editing && <ItemForm initialValue={editing === 'new' ? null : editing} onSubmit={save} onClose={() => setEditing(null)} />}{deleting && <ConfirmDialog title="Delete item" message={`Delete “${deleting.name}”? This cannot be undone.`} onClose={() => setDeleting(null)} onConfirm={confirmDelete} />}</>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default ItemsPage;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Provides a minimal placeholder page to demonstrate adding routed screens.
|
|
2
|
+
import PageHeader from '../components/common/PageHeader';
|
|
3
|
+
import { surfaceStyle } from '../config/theme';
|
|
4
|
+
|
|
5
|
+
function ProfilePage() {
|
|
6
|
+
return <><PageHeader title="Profile" subtitle="Replace this placeholder with project-specific content." /><section style={{ ...surfaceStyle, padding: 24 }}><strong>Jane Doe</strong><p className="text-secondary small mb-0 mt-2">Application user</p></section></>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default ProfilePage;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# TaskFlow MERN Application
|
|
2
|
+
|
|
3
|
+
TaskFlow is a two-screen task-management dashboard built with Create React App, Bootstrap, Express, Mongoose, and MongoDB.
|
|
4
|
+
|
|
5
|
+
## Application screenshots
|
|
6
|
+
|
|
7
|
+
### Dashboard
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
### Tasks
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
### Add task form
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
## Run locally
|
|
20
|
+
|
|
21
|
+
1. Start MongoDB at `mongodb://127.0.0.1:27017`.
|
|
22
|
+
2. In `backend`, run `npm install`, then `npm run seed`, then `npm start` (API port `5001`).
|
|
23
|
+
3. In `frontend`, run `npm install`, then `npm start`.
|
|
24
|
+
4. Open `http://localhost:3000`.
|
|
25
|
+
|
|
26
|
+
The backend uses the values in `backend/.env`. The seed command imports `backend/data/taskflow-data.json` into the `taskflow` database.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
- `GET /api/tasks` supports `priority`, `status`, and `search` query parameters.
|
|
31
|
+
- `POST /api/tasks` creates a task.
|
|
32
|
+
- `PUT /api/tasks/:id` replaces a task.
|
|
33
|
+
- `PATCH /api/tasks/:id` updates selected task fields.
|
|
34
|
+
- `DELETE /api/tasks/:id` deletes a task.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Configures the Express application, middleware, health check, and API routes.
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const cors = require('cors');
|
|
4
|
+
const taskRoutes = require('./routes/taskRoutes');
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
app.use(cors());
|
|
8
|
+
app.use(express.json());
|
|
9
|
+
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
10
|
+
app.use('/api/tasks', taskRoutes);
|
|
11
|
+
app.use((error, req, res, next) => {
|
|
12
|
+
if (res.headersSent) return next(error);
|
|
13
|
+
const status = error.name === 'ValidationError' ? 400 : 500;
|
|
14
|
+
return res.status(status).json({ message: error.message });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
module.exports = app;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Opens the shared Mongoose connection used by the API and seed script.
|
|
2
|
+
const mongoose = require('mongoose');
|
|
3
|
+
|
|
4
|
+
const connectDB = async () => {
|
|
5
|
+
const connection = await mongoose.connect(
|
|
6
|
+
process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/taskflow'
|
|
7
|
+
);
|
|
8
|
+
console.log(`MongoDB connected: ${connection.connection.host}`);
|
|
9
|
+
return connection;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
module.exports = connectDB;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Implements all REST operations and query filtering for TaskFlow tasks.
|
|
2
|
+
const mongoose = require('mongoose');
|
|
3
|
+
const Task = require('../models/Task');
|
|
4
|
+
|
|
5
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
|
|
7
|
+
const buildFilter = ({ priority, status, search }) => {
|
|
8
|
+
const filter = {};
|
|
9
|
+
if (priority) filter.priority = priority;
|
|
10
|
+
if (status) filter.$or = [{ simpleStatus: status }, { detailedStatus: status }];
|
|
11
|
+
if (search) {
|
|
12
|
+
const matcher = new RegExp(escapeRegex(search), 'i');
|
|
13
|
+
const conditions = [{ name: matcher }, { description: matcher }];
|
|
14
|
+
if (filter.$or) filter.$and = [{ $or: filter.$or }, { $or: conditions }];
|
|
15
|
+
else filter.$or = conditions;
|
|
16
|
+
}
|
|
17
|
+
return filter;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const getTasks = async (req, res, next) => {
|
|
21
|
+
try {
|
|
22
|
+
const tasks = await Task.find(buildFilter(req.query)).sort({ createdAt: 1 });
|
|
23
|
+
res.json(tasks);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
next(error);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createTask = async (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
const task = await Task.create(req.body);
|
|
32
|
+
res.status(201).json(task);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
next(error);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const updateTask = async (req, res, next) => {
|
|
39
|
+
try {
|
|
40
|
+
if (!mongoose.isValidObjectId(req.params.id)) {
|
|
41
|
+
return res.status(400).json({ message: 'Invalid task id' });
|
|
42
|
+
}
|
|
43
|
+
const task = await Task.findByIdAndUpdate(req.params.id, req.body, {
|
|
44
|
+
new: true,
|
|
45
|
+
runValidators: true,
|
|
46
|
+
overwrite: req.method === 'PUT',
|
|
47
|
+
});
|
|
48
|
+
if (!task) return res.status(404).json({ message: 'Task not found' });
|
|
49
|
+
return res.json(task);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return next(error);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const deleteTask = async (req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
if (!mongoose.isValidObjectId(req.params.id)) {
|
|
58
|
+
return res.status(400).json({ message: 'Invalid task id' });
|
|
59
|
+
}
|
|
60
|
+
const task = await Task.findByIdAndDelete(req.params.id);
|
|
61
|
+
if (!task) return res.status(404).json({ message: 'Task not found' });
|
|
62
|
+
return res.json({ message: 'Task deleted' });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return next(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports = { getTasks, createTask, updateTask, deleteTask };
|