Fix QA blockers: rebuild the frontend TypeScript layer
The committed .ts/.tsx files were written through a shell heredoc that stripped every quote character, so types.ts, App.tsx, api.ts, TaskList.tsx, TaskRow.tsx, TaskCard.tsx and AppShell.tsx were not valid TypeScript. Nothing surfaced this because index.js imported ./App, which resolved to the Create React App template App.js ahead of App.tsx, and typescript was never added as a dependency, so the broken files were never compiled. The preview was showing the CRA starter page. Rewritten and transferred as a binary archive rather than through a shell, with tsc and the test suite as the check that the files actually parse. Priority 1 (blocking) - TaskForm.tsx implemented: title, description, assignee, status, due date and priority, validated with react-hook-form and actionable messages. - App.tsx is now a real container wired to the service layer, replacing the hardcoded mock markup. Filtering by My Tasks / All Tasks / Blocked is driven by the signed-in user instead of a hardcoded id. - Edit, delete and inline status changes work from both the table and the cards. Priority 2 - Accessibility: skip link, ARIA tablist with arrow keys, table roles, dialog with focus trap and restore, aria-invalid and aria-describedby on fields, polite live region, 44px targets. Contrast corrected to meet AA: buttons use #2563eb and accent text #1d4ed8, since the mockup #3b82f6 reaches only 3.7:1 behind white text. - Error handling: ErrorBoundary, load failure with retry, per-filter empty states, blocked guidance, and save failures that keep the dialog and the user's input on screen. - 17 integration tests replace the CRA placeholder test. Removed the dead CRA template files (App.js, index.js, logo.svg, reportWebVitals.js, App_temp.css) and added tsconfig.json plus typescript. Verified in devbox 306: tsc --noEmit clean, 17/17 tests pass, CI build compiles with no lint warnings. Screenshots of all six states are in frontend/preview/.
@ -1,70 +1,102 @@
|
||||
# Getting Started with Create React App
|
||||
# TaskFlow frontend
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
React 19 + TypeScript single-page app built with Create React App, implementing
|
||||
the approved mockups in `architect/01-mockups/` and the contract in
|
||||
`architect/03-feasibility/frontend-handoff.md`.
|
||||
|
||||
## Available Scripts
|
||||
## Commands
|
||||
|
||||
In the project directory, you can run:
|
||||
Run these from `frontend/`:
|
||||
|
||||
### `npm start`
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `npm install` | Install dependencies |
|
||||
| `npm start` | Dev server with hot reload (`HOST=0.0.0.0 PORT=5173` to expose it) |
|
||||
| `npm run build` | Production build into `build/` |
|
||||
| `npm test` | Jest + React Testing Library, watch mode |
|
||||
| `npm run test:ci` | Single non-interactive test run |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
`CI=true npm run build` treats lint warnings as errors, which is how the build is
|
||||
verified before hand-off.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
## Layout
|
||||
|
||||
### `npm test`
|
||||
```
|
||||
src/
|
||||
├── App.tsx container: data loading, filtering, save/delete
|
||||
├── App.css reset, design tokens, shared primitives
|
||||
├── types.ts Task/User types and the labels used in the UI
|
||||
├── services/api.ts REST client (currently an in-memory stand-in)
|
||||
└── components/
|
||||
├── common/
|
||||
│ ├── AppShell.tsx header, team sidebar, main region
|
||||
│ ├── TaskFields.tsx avatar, priority, status control, due date
|
||||
│ └── ErrorBoundary.tsx render-crash recovery screen
|
||||
├── TaskList/
|
||||
│ ├── TaskList.tsx filter tabs, empty states, blocked guidance
|
||||
│ ├── TaskRow.tsx desktop table row
|
||||
│ └── TaskCard.tsx mobile card
|
||||
└── TaskForm/TaskForm.tsx create/edit dialog with validation
|
||||
```
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
## Backend contract
|
||||
|
||||
### `npm run build`
|
||||
`src/services/api.ts` is the only file that talks to the server. It is an
|
||||
in-memory stand-in whose method names and payloads match the documented
|
||||
endpoints, so the Backend phase can replace the method bodies with `fetch` or
|
||||
`axios` calls without touching a component:
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `getCurrentUser()` | `GET /api/auth/me` |
|
||||
| `getUsers()` | `GET /api/users` |
|
||||
| `getTasks()` | `GET /api/tasks` |
|
||||
| `createTask(draft)` | `POST /api/tasks` |
|
||||
| `updateTask(id, changes)` | `PUT /api/tasks/:id` |
|
||||
| `deleteTask(id)` | `DELETE /api/tasks/:id` |
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
Draft validation lives in `validateDraft` inside that module as well as in the
|
||||
form, because the server is the real trust boundary and the same rules have to
|
||||
hold there.
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
## States covered
|
||||
|
||||
### `npm run eject`
|
||||
Screenshots of each are in `preview/`.
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
- **Loading** — spinner with `role="status"` while the first fetch runs.
|
||||
- **Load failure** — `role="alert"` with the reason and a Try again button.
|
||||
- **Empty** — distinct copy for no tasks at all, nothing assigned to you, and
|
||||
nothing blocked, so an active filter is never mistaken for an empty account.
|
||||
- **Blocked** — explanatory notice above the list, per the blocked-state mockup.
|
||||
- **Validation** — per-field messages tied to inputs with `aria-describedby`.
|
||||
- **Save failure** — the dialog stays open with the user's input intact.
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
## Accessibility
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
Targeting WCAG 2.1 AA.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
- Skip link to the task list; single visible focus style via `:focus-visible`.
|
||||
- Filter tabs are an ARIA tablist with arrow-key navigation and counts in the
|
||||
accessible name.
|
||||
- The task table exposes `table`/`rowgroup`/`row`/`columnheader`/`cell` roles.
|
||||
- The dialog is `role="dialog" aria-modal="true"`, focuses the title field on
|
||||
open, traps Tab, closes on Escape, and returns focus to whatever opened it.
|
||||
- Errors use `role="alert"` and `aria-invalid`; confirmations go to a polite
|
||||
live region.
|
||||
- Interactive targets are at least 44px.
|
||||
- Contrast: the mockup accent `#3b82f6` only reaches 3.7:1 behind white text, so
|
||||
filled buttons use `#2563eb` (5.2:1) and accent text `#1d4ed8` (6.4:1). Amber
|
||||
status text was darkened to `#92400e` for the same reason.
|
||||
|
||||
## Learn More
|
||||
Still to do by hand, since they cannot be automated here: screen reader passes
|
||||
(NVDA/VoiceOver), 200% browser zoom, and cross-browser checks on Firefox, Safari
|
||||
and Edge. Note that `@axe-core/react` is listed as a dependency but is not wired
|
||||
up: version 4 relies on `ReactDOM.findDOMNode`, which React 19 removed.
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
## Responsive behaviour
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
One breakpoint at 768px. The desktop table and the mobile cards are both
|
||||
rendered and CSS picks one, so the hidden view is also removed from the
|
||||
accessibility tree and no task is announced twice. Below 768px the team sidebar
|
||||
is hidden and Create Task becomes a floating action button.
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# Frontend Implementation
|
||||
|
||||
This directory will contain the user interface code and assets.
|
||||
|
||||
*Content will be generated by the Frontend agent after architecture approval.*
|
||||
234
frontend/package-lock.json
generated
@ -13,6 +13,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "20.17.9",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"axios": "^1.20.0",
|
||||
@ -21,6 +23,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.87.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "5.6.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
}
|
||||
},
|
||||
@ -2647,6 +2650,27 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/expect-utils": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
|
||||
"integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jest-get-type": "^29.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/expect-utils/node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/fake-timers": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz",
|
||||
@ -3672,6 +3696,193 @@
|
||||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest": {
|
||||
"version": "29.5.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
|
||||
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expect": "^29.0.0",
|
||||
"pretty-format": "^29.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/@jest/schemas": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.27.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/@jest/types": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
|
||||
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "^29.6.3",
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^17.0.8",
|
||||
"chalk": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.12",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
|
||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
"integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/expect": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
||||
"integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/expect-utils": "^29.7.0",
|
||||
"jest-get-type": "^29.6.3",
|
||||
"jest-matcher-utils": "^29.7.0",
|
||||
"jest-message-util": "^29.7.0",
|
||||
"jest-util": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/jest-diff": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
|
||||
"integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"diff-sequences": "^29.6.3",
|
||||
"jest-get-type": "^29.6.3",
|
||||
"pretty-format": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/jest-matcher-utils": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
|
||||
"integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"jest-diff": "^29.7.0",
|
||||
"jest-get-type": "^29.6.3",
|
||||
"pretty-format": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/jest-message-util": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
|
||||
"integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.13",
|
||||
"@jest/types": "^29.6.3",
|
||||
"@types/stack-utils": "^2.0.0",
|
||||
"chalk": "^4.0.0",
|
||||
"graceful-fs": "^4.2.9",
|
||||
"micromatch": "^4.0.4",
|
||||
"pretty-format": "^29.7.0",
|
||||
"slash": "^3.0.0",
|
||||
"stack-utils": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/jest-util": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
|
||||
"integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/types": "^29.6.3",
|
||||
"@types/node": "*",
|
||||
"chalk": "^4.0.0",
|
||||
"ci-info": "^3.2.0",
|
||||
"graceful-fs": "^4.2.9",
|
||||
"picomatch": "^2.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "^29.6.3",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^18.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@ -3691,12 +3902,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||
"version": "20.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz",
|
||||
"integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
@ -16343,17 +16554,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
@ -16381,9 +16591,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "20.17.9",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"axios": "^1.20.0",
|
||||
@ -16,12 +18,15 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.87.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "5.6.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"test:ci": "react-scripts test --watchAll=false",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
BIN
frontend/preview/taskflow-blocked-desktop.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
frontend/preview/taskflow-create-task-desktop.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/preview/taskflow-create-task-mobile.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/preview/taskflow-dashboard-desktop.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
frontend/preview/taskflow-dashboard-mobile.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 42 KiB |
BIN
frontend/preview/taskflow-validation-desktop.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
@ -1,107 +1,495 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
/*
|
||||
* TaskFlow base styles: reset, shared primitives and design tokens.
|
||||
* Palette follows the approved mockups. Two swaps were made for WCAG 2.1 AA:
|
||||
* the mockup accent #3b82f6 only reaches 3.7:1 behind white text, so filled
|
||||
* buttons use #2563eb (5.2:1) and accent text uses #1d4ed8 (6.4:1).
|
||||
*/
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Visible only to screen readers. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Keyboard users can jump straight to the task list. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #1e293b;
|
||||
color: white;
|
||||
border-radius: 0 0 0.375rem 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Page-level loading and error states */
|
||||
.app-loading,
|
||||
.app-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: white;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
.loading-spinner {
|
||||
border: 4px solid #e2e8f0;
|
||||
border-left-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.app-error h1 {
|
||||
color: #b91c1c;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-error p {
|
||||
color: #475569;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Inline notices (save confirmations, failed updates) */
|
||||
.app-notice-region {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-notice {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
background: white;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
background: white;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 120px 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
animation: notice-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
@keyframes notice-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.task-status.open { background: #dbeafe; color: #1d4ed8; }
|
||||
.task-status.in_progress { background: #fef3c7; color: #d97706; }
|
||||
.task-status.blocked { background: #fecaca; color: #dc2626; }
|
||||
|
||||
.task-priority {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
.app-notice.info {
|
||||
background-color: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.task-priority.low { background: #dcfce7; color: #166534; }
|
||||
.task-priority.medium { background: #fef3c7; color: #a16207; }
|
||||
.task-priority.high { background: #fecaca; color: #991b1b; }
|
||||
.app-notice.error {
|
||||
background-color: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.notice-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.notice-dismiss:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
min-height: 44px; /* WCAG 2.5.5 touch target */
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: #334155;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #b91c1c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #991b1b;
|
||||
}
|
||||
|
||||
.create-task-btn {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Icon-only actions keep a 44px hit area even though the glyph is small. */
|
||||
.btn-action {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.btn-action:hover:not(:disabled) {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-action.delete:hover:not(:disabled) {
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* One consistent, always-visible focus indicator */
|
||||
:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Avatars */
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: #e2e8f0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: #334155;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.user-avatar.sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.user-avatar.lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Priority: coloured dot plus label, as in the dashboard mockup */
|
||||
.priority-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.priority-indicator.low {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.priority-indicator.low .priority-dot {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.priority-indicator.medium {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.priority-indicator.medium .priority-dot {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
.priority-indicator.high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.priority-indicator.high .priority-dot {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
/* Status pill, doubling as the status control */
|
||||
.status-badge,
|
||||
.status-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
min-height: 32px;
|
||||
padding-right: 1.75rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 8px;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 5'%3E%3Cpath fill='%23334155' d='M0 0h8L4 5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.status-select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.status-badge.open,
|
||||
.status-select.open {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-badge.in_progress,
|
||||
.status-select.in_progress {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-badge.blocked,
|
||||
.status-select.blocked {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Assignee */
|
||||
.assignee-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.assignee-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.unassigned {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Due dates */
|
||||
.due-date {
|
||||
font-size: 0.875rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.due-date.none {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.due-date.overdue {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Form primitives (shared by the task dialog) */
|
||||
.form-group {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: #334155;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.form-label.required::after {
|
||||
content: ' *';
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
background-color: white;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-textarea::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input:hover,
|
||||
.form-select:hover,
|
||||
.form-textarea:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.form-input[aria-invalid='true'],
|
||||
.form-select[aria-invalid='true'],
|
||||
.form-textarea[aria-invalid='true'] {
|
||||
border-color: #b91c1c;
|
||||
background-color: #fffbfb;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.required-marker {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-error-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
305
frontend/src/App.test.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import App from './App';
|
||||
import { api } from './services/api';
|
||||
import { Task, User } from './types';
|
||||
|
||||
jest.mock('./services/api', () => ({
|
||||
...jest.requireActual('./services/api'),
|
||||
api: {
|
||||
getCurrentUser: jest.fn(),
|
||||
getUsers: jest.fn(),
|
||||
getTasks: jest.fn(),
|
||||
createTask: jest.fn(),
|
||||
updateTask: jest.fn(),
|
||||
deleteTask: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const sarah: User = { id: 1, username: 'sarah.chen', full_name: 'Sarah Chen' };
|
||||
const alex: User = { id: 2, username: 'alex.rivera', full_name: 'Alex Rivera' };
|
||||
|
||||
const myTask: Task = {
|
||||
id: 1,
|
||||
title: 'Q4 Marketing Strategy',
|
||||
description: 'Outline key campaigns and budget',
|
||||
assigned_to: sarah,
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
due_date: '2026-10-20',
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
updated_at: '2026-09-01T10:00:00Z',
|
||||
};
|
||||
|
||||
const blockedTask: Task = {
|
||||
id: 2,
|
||||
title: 'API Integration Debugging',
|
||||
description: 'Fixing errors in payment gateway',
|
||||
assigned_to: alex,
|
||||
status: 'blocked',
|
||||
priority: 'high',
|
||||
due_date: '2026-10-18',
|
||||
created_at: '2026-09-02T10:00:00Z',
|
||||
updated_at: '2026-09-02T10:00:00Z',
|
||||
};
|
||||
|
||||
/** The desktop table and the mobile cards are both in the DOM (CSS picks one),
|
||||
* so row-level assertions are scoped to the table. */
|
||||
const table = () => within(screen.getByRole('table'));
|
||||
|
||||
const renderApp = async (): Promise<void> => {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockApi.getCurrentUser.mockResolvedValue(sarah);
|
||||
mockApi.getUsers.mockResolvedValue([sarah, alex]);
|
||||
mockApi.getTasks.mockResolvedValue([myTask, blockedTask]);
|
||||
});
|
||||
|
||||
describe('loading the dashboard', () => {
|
||||
it('shows a loading state, then the team task table', async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
|
||||
expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument();
|
||||
expect(table().getByText('API Integration Debugging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a retry when the request fails, and recovers on retry', async () => {
|
||||
mockApi.getTasks.mockRejectedValueOnce(new Error('Network request failed'));
|
||||
|
||||
render(<App />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Network request failed');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
|
||||
expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
it('limits My Tasks to the signed-in user', async () => {
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /my tasks/i }));
|
||||
|
||||
expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument();
|
||||
expect(table().queryByText('API Integration Debugging')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why work is blocked in the Blocked view', async () => {
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /blocked/i }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: /these tasks are blocked/i })).toBeInTheDocument();
|
||||
expect(table().getByText('API Integration Debugging')).toBeInTheDocument();
|
||||
expect(table().queryByText('Q4 Marketing Strategy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports an empty filter without pretending there are no tasks at all', async () => {
|
||||
mockApi.getTasks.mockResolvedValue([myTask]);
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /blocked/i }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: /no blocked tasks/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('creating a task', () => {
|
||||
it('creates and lists a task in three steps', async () => {
|
||||
const created: Task = {
|
||||
...myTask,
|
||||
id: 3,
|
||||
title: 'Update client onboarding documentation',
|
||||
description: '',
|
||||
assigned_to: alex,
|
||||
priority: 'high',
|
||||
due_date: '2026-11-01',
|
||||
};
|
||||
mockApi.createTask.mockResolvedValue(created);
|
||||
|
||||
await renderApp();
|
||||
|
||||
// 1. open the form
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create task' }));
|
||||
expect(screen.getByRole('dialog', { name: /create new task/i })).toBeInTheDocument();
|
||||
|
||||
// 2. fill in the required fields
|
||||
fireEvent.change(screen.getByLabelText(/task title/i), {
|
||||
target: { value: 'Update client onboarding documentation' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/assignee/i), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: '2026-11-01' } });
|
||||
fireEvent.change(screen.getByLabelText(/priority/i), { target: { value: 'high' } });
|
||||
|
||||
// 3. save
|
||||
await userEvent.click(screen.getByRole('button', { name: /save task/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockApi.createTask).toHaveBeenCalledWith({
|
||||
title: 'Update client onboarding documentation',
|
||||
description: '',
|
||||
assignee_id: 2,
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
due_date: '2026-11-01',
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(table().getByText('Update client onboarding documentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains what to fix instead of submitting an incomplete task', async () => {
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create task' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /save task/i }));
|
||||
|
||||
expect(await screen.findByText(/enter a task title/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/choose the team member who owns this task/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pick a due date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/task title/i)).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(mockApi.createTask).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the form and its input on screen when saving fails', async () => {
|
||||
mockApi.createTask.mockRejectedValue(new Error('Connection lost before the task was saved.'));
|
||||
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create task' }));
|
||||
fireEvent.change(screen.getByLabelText(/task title/i), { target: { value: 'Draft that must survive' } });
|
||||
fireEvent.change(screen.getByLabelText(/assignee/i), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: '2026-11-01' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /save task/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Connection lost before the task was saved.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/task title/i)).toHaveValue('Draft that must survive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updating and deleting', () => {
|
||||
it('changes status straight from the list', async () => {
|
||||
mockApi.updateTask.mockResolvedValue({ ...myTask, status: 'in_progress' });
|
||||
|
||||
await renderApp();
|
||||
|
||||
fireEvent.change(table().getByLabelText('Status for Q4 Marketing Strategy'), {
|
||||
target: { value: 'in_progress' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockApi.updateTask).toHaveBeenCalledWith(1, { status: 'in_progress' }));
|
||||
expect(await screen.findByText(/is now in progress/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the task untouched and says so when an update fails', async () => {
|
||||
mockApi.updateTask.mockRejectedValue(new Error('Could not reach the server.'));
|
||||
|
||||
await renderApp();
|
||||
|
||||
fireEvent.change(table().getByLabelText('Status for Q4 Marketing Strategy'), {
|
||||
target: { value: 'blocked' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Could not reach the server.')).toBeInTheDocument();
|
||||
expect(table().getByLabelText('Status for Q4 Marketing Strategy')).toHaveValue('open');
|
||||
});
|
||||
|
||||
it('opens the edit dialog prefilled from the row', async () => {
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(table().getByRole('button', { name: 'Edit Q4 Marketing Strategy' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /edit task/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/task title/i)).toHaveValue('Q4 Marketing Strategy');
|
||||
expect(screen.getByLabelText(/assignee/i)).toHaveValue('1');
|
||||
});
|
||||
|
||||
it('asks before deleting and does nothing if the user backs out', async () => {
|
||||
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
await renderApp();
|
||||
await userEvent.click(table().getByRole('button', { name: 'Delete Q4 Marketing Strategy' }));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(mockApi.deleteTask).not.toHaveBeenCalled();
|
||||
expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes once confirmed', async () => {
|
||||
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockApi.deleteTask.mockResolvedValue(undefined);
|
||||
|
||||
await renderApp();
|
||||
await userEvent.click(table().getByRole('button', { name: 'Delete Q4 Marketing Strategy' }));
|
||||
|
||||
await waitFor(() => expect(mockApi.deleteTask).toHaveBeenCalledWith(1));
|
||||
await waitFor(() => expect(table().queryByText('Q4 Marketing Strategy')).not.toBeInTheDocument());
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard and screen reader support', () => {
|
||||
it('labels the filter tabs as a tablist with counts', async () => {
|
||||
await renderApp();
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(3);
|
||||
expect(screen.getByRole('tab', { name: /all tasks, 2 tasks/i })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('moves between filter tabs with the arrow keys', async () => {
|
||||
await renderApp();
|
||||
|
||||
const allTab = screen.getByRole('tab', { name: /all tasks/i });
|
||||
allTab.focus();
|
||||
fireEvent.keyDown(allTab, { key: 'ArrowRight' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('tab', { name: /blocked/i })).toHaveAttribute('aria-selected', 'true')
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the dialog on Escape without saving', async () => {
|
||||
await renderApp();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create task' }));
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(mockApi.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes every column header and a skip link', async () => {
|
||||
await renderApp();
|
||||
|
||||
['Priority', 'Task Name', 'Assignee', 'Status', 'Due Date', 'Actions'].forEach((header) => {
|
||||
expect(table().getByRole('columnheader', { name: header })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('link', { name: /skip to task list/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,71 +1,201 @@
|
||||
import React, { useState, useEffect } from " react\;
|
||||
import \./App.css\;
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AppShell from './components/common/AppShell';
|
||||
import TaskForm from './components/TaskForm/TaskForm';
|
||||
import TaskList, { FilterCounts } from './components/TaskList/TaskList';
|
||||
import { api } from './services/api';
|
||||
import { Task, TaskDraft, TaskFilter, TaskStatus, User } from './types';
|
||||
import './App.css';
|
||||
|
||||
const mockTasks = [
|
||||
{ id: 1, title: \Update documentation\, status: \in_progress\, priority: \high\, assignee: \Sarah Chen\ },
|
||||
{ id: 2, title: \Fix login bug\, status: \blocked\, priority: \high\, assignee: \Mike Johnson\ },
|
||||
{ id: 3, title: \Design dashboard\, status: \open\, priority: \medium\, assignee: \Alex Kim\ }
|
||||
];
|
||||
interface Notice {
|
||||
kind: 'info' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const errorText = (error: unknown, fallback: string): string =>
|
||||
error instanceof Error && error.message ? error.message : fallback;
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState(\my\);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [filter, setFilter] = useState<TaskFilter>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [pendingTaskId, setPendingTaskId] = useState<number | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
|
||||
/** Element that opened the dialog, so focus can go back where the user left it. */
|
||||
const formTriggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const [me, teamMembers, taskList] = await Promise.all([
|
||||
api.getCurrentUser(),
|
||||
api.getUsers(),
|
||||
api.getTasks(),
|
||||
]);
|
||||
setCurrentUser(me);
|
||||
setUsers(teamMembers);
|
||||
setTasks(taskList);
|
||||
} catch (error) {
|
||||
setLoadError(
|
||||
errorText(error, 'TaskFlow could not reach the server. Check your connection and retry.')
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const counts: FilterCounts = useMemo(
|
||||
() => ({
|
||||
my: tasks.filter((task) => task.assigned_to?.id === currentUser?.id).length,
|
||||
all: tasks.length,
|
||||
blocked: tasks.filter((task) => task.status === 'blocked').length,
|
||||
}),
|
||||
[tasks, currentUser]
|
||||
);
|
||||
|
||||
const visibleTasks = useMemo(() => {
|
||||
if (filter === 'my') {
|
||||
return tasks.filter((task) => task.assigned_to?.id === currentUser?.id);
|
||||
}
|
||||
if (filter === 'blocked') {
|
||||
return tasks.filter((task) => task.status === 'blocked');
|
||||
}
|
||||
return tasks;
|
||||
}, [tasks, filter, currentUser]);
|
||||
|
||||
const openForm = (task: Task | null): void => {
|
||||
formTriggerRef.current = document.activeElement as HTMLElement | null;
|
||||
setEditingTask(task);
|
||||
setNotice(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = (): void => {
|
||||
setFormOpen(false);
|
||||
setEditingTask(null);
|
||||
formTriggerRef.current?.focus();
|
||||
};
|
||||
|
||||
/** Rejects on failure so TaskForm can keep the user's input on screen. */
|
||||
const saveTask = async (draft: TaskDraft): Promise<void> => {
|
||||
if (editingTask) {
|
||||
const updated = await api.updateTask(editingTask.id, draft);
|
||||
setTasks((current) => current.map((task) => (task.id === updated.id ? updated : task)));
|
||||
setNotice({ kind: 'info', text: `"${updated.title}" was updated.` });
|
||||
} else {
|
||||
const created = await api.createTask(draft);
|
||||
setTasks((current) => [created, ...current]);
|
||||
setNotice({ kind: 'info', text: `"${created.title}" was created.` });
|
||||
}
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const changeStatus = async (task: Task, status: TaskStatus): Promise<void> => {
|
||||
setPendingTaskId(task.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const updated = await api.updateTask(task.id, { status });
|
||||
setTasks((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setNotice({ kind: 'info', text: `"${updated.title}" is now ${status.replace('_', ' ')}.` });
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text: errorText(error, `"${task.title}" could not be updated. It is unchanged, so try again.`),
|
||||
});
|
||||
} finally {
|
||||
setPendingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (task: Task): Promise<void> => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const confirmed = window.confirm(`Delete "${task.title}"? This cannot be undone.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTaskId(task.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.deleteTask(task.id);
|
||||
setTasks((current) => current.filter((item) => item.id !== task.id));
|
||||
setNotice({ kind: 'info', text: `"${task.title}" was deleted.` });
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text: errorText(error, `"${task.title}" could not be deleted. Nothing changed, so try again.`),
|
||||
});
|
||||
} finally {
|
||||
setPendingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-loading" role="status">
|
||||
<div className="loading-spinner" aria-hidden="true" />
|
||||
<p>Loading your team's tasks...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError || !currentUser) {
|
||||
return (
|
||||
<div className="app-error" role="alert">
|
||||
<h1>We could not load your tasks</h1>
|
||||
<p>{loadError ?? 'Your account details are unavailable right now.'}</p>
|
||||
<button type="button" className="btn btn-primary retry-button" onClick={load}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=\App\>
|
||||
<header className=\app-header\>
|
||||
<h1>TaskFlow</h1>
|
||||
<div className=\user-profile\>
|
||||
<span>Sarah Chen</span>
|
||||
<button className=\create-btn\>Create Task</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className=\app-main\>
|
||||
<div className=\sidebar\>
|
||||
<h3>Team Members</h3>
|
||||
<div className=\team-list\>
|
||||
<div className=\team-member\>Sarah Chen</div>
|
||||
<div className=\team-member\>Mike Johnson</div>
|
||||
<div className=\team-member\>Alex Kim</div>
|
||||
<div className=\team-member\>Emma Davis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=\content\>
|
||||
<div className=\filter-tabs\>
|
||||
<AppShell currentUser={currentUser} users={users}>
|
||||
<div className="app-notice-region" aria-live="polite">
|
||||
{notice && (
|
||||
<div className={`app-notice ${notice.kind}`}>
|
||||
<span>{notice.text}</span>
|
||||
<button
|
||||
className={\ ab \}
|
||||
onClick={() => setFilter(\my\)}
|
||||
type="button"
|
||||
className="notice-dismiss"
|
||||
onClick={() => setNotice(null)}
|
||||
aria-label="Dismiss message"
|
||||
>
|
||||
My Tasks (1)
|
||||
</button>
|
||||
<button
|
||||
className={\ ab \}
|
||||
onClick={() => setFilter(\all\)}
|
||||
>
|
||||
All Tasks (3)
|
||||
</button>
|
||||
<button
|
||||
className={\ ab \}
|
||||
onClick={() => setFilter(\blocked\)}
|
||||
>
|
||||
Blocked (1)
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className=\task-list\>
|
||||
{mockTasks.map(task => (
|
||||
<div key={task.id} className=\task-row\>
|
||||
<div className=\task-title\>{task.title}</div>
|
||||
<div className=\task-assignee\>{task.assignee}</div>
|
||||
<div className={\ ask-status \}>{task.status}</div>
|
||||
<div className={\ ask-priority \}>{task.priority}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={visibleTasks}
|
||||
totalTaskCount={tasks.length}
|
||||
counts={counts}
|
||||
currentFilter={filter}
|
||||
pendingTaskId={pendingTaskId}
|
||||
onFilterChange={setFilter}
|
||||
onCreateTask={() => openForm(null)}
|
||||
onEditTask={openForm}
|
||||
onDeleteTask={deleteTask}
|
||||
onStatusChange={changeStatus}
|
||||
/>
|
||||
|
||||
{formOpen && (
|
||||
<TaskForm users={users} task={editingTask} onCancel={closeForm} onSubmit={saveTask} />
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
/* TaskFlow App Styles */
|
||||
|
||||
/* CSS Reset and Base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, " Segoe UI\, \Roboto\, \Helvetica Neue\, Arial, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.app-loading,
|
||||
.app-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid #e2e8f0;
|
||||
border-left: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-error h1 {
|
||||
color: #dc2626;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.retry-button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.open {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-badge.in_progress {
|
||||
background-color: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-badge.blocked {
|
||||
background-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Priority Indicators */
|
||||
.priority-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.priority-indicator.low {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.priority-indicator.medium {
|
||||
background-color: #fef3c7;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.priority-indicator.high {
|
||||
background-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* User Avatar */
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.user-avatar.sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar.lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
min-height: 44px; /* WCAG touch target */
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-label.required::after {
|
||||
@ -1,54 +1,93 @@
|
||||
/* Create/Edit task dialog, per the approved desktop form mockup */
|
||||
.task-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
inset: 0;
|
||||
background-color: rgba(15, 23, 42, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
z-index: 1000;
|
||||
animation: overlay-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.task-form-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
background-color: white;
|
||||
border-radius: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(15, 23, 42, 0.25);
|
||||
animation: dialog-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.task-form {
|
||||
padding: 2rem;
|
||||
/* Vertical padding lives on the sticky header and footer instead, so they can
|
||||
cover the scrolling content edge to edge. */
|
||||
padding: 0 1.75rem;
|
||||
}
|
||||
|
||||
/* Title and actions stay put while the fields scroll, so Save is always
|
||||
reachable even when validation messages make the form taller than the window. */
|
||||
.form-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 0 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
.close-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@ -57,6 +96,11 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Status: three radio options shown as pills, matching the mockup */
|
||||
.status-fieldset {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@ -67,50 +111,102 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-option input {
|
||||
display: none;
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.status-option input[type='radio'] {
|
||||
/* Kept visible and focusable; the pill is the hit area around it. */
|
||||
margin: 0;
|
||||
accent-color: #2563eb;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.status-option input:checked + .status-pill {
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
.status-option:hover .status-pill {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-option input[type='radio']:checked + .status-pill {
|
||||
border-color: #2563eb;
|
||||
background-color: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-option input[type='radio']:focus-visible + .status-pill {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 0 1.25rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Full-screen sheet on mobile, per the mobile create-task mockup. */
|
||||
.task-form-overlay {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-form-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
justify-content: stretch;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
flex: 1;
|
||||
.form-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.close-btn .close-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
DESCRIPTION_MAX_LENGTH,
|
||||
PRIORITY_LABELS,
|
||||
STATUS_LABELS,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
Task,
|
||||
TaskDraft,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
TITLE_MAX_LENGTH,
|
||||
User,
|
||||
} from '../../types';
|
||||
import './TaskForm.css';
|
||||
|
||||
interface TaskFormProps {
|
||||
users: User[];
|
||||
/** Task being edited, or null to create a new one. */
|
||||
task: Task | null;
|
||||
onCancel: () => void;
|
||||
/** Rejects with a user-safe message; the form stays open so nothing is lost. */
|
||||
onSubmit: (draft: TaskDraft) => Promise<void>;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
due_date: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href]';
|
||||
|
||||
const TaskForm: React.FC<TaskFormProps> = ({ users, task, onCancel, onSubmit }) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: task?.title ?? '',
|
||||
description: task?.description ?? '',
|
||||
assignee_id: task?.assigned_to ? String(task.assigned_to.id) : '',
|
||||
status: task?.status ?? 'open',
|
||||
priority: task?.priority ?? 'medium',
|
||||
due_date: task?.due_date ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const submit = async (values: FormValues): Promise<void> => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await onSubmit({
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
assignee_id: Number(values.assignee_id),
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
due_date: values.due_date,
|
||||
});
|
||||
} catch (error) {
|
||||
setSubmitError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The task could not be saved. Your details are still here, so you can try again.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Escape closes; Tab stays inside the dialog. */
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab' || !dialogRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const titleId = 'task-form-title';
|
||||
|
||||
return (
|
||||
<div className="task-form-overlay">
|
||||
<div
|
||||
className="task-form-container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
ref={dialogRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<form className="task-form" onSubmit={handleSubmit(submit)} noValidate>
|
||||
<div className="form-header">
|
||||
<h2 id={titleId}>{task ? 'Edit Task' : 'Create New Task'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={onCancel}
|
||||
aria-label="Close without saving"
|
||||
>
|
||||
<span className="close-text">Close</span>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p className="form-error-banner" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label required" htmlFor="task-title">
|
||||
Task Title
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g., Update client onboarding documentation"
|
||||
aria-required="true"
|
||||
aria-invalid={errors.title ? 'true' : 'false'}
|
||||
aria-describedby={errors.title ? 'task-title-error' : undefined}
|
||||
{...register('title', {
|
||||
required: 'Enter a task title so your team knows what to do.',
|
||||
maxLength: {
|
||||
value: TITLE_MAX_LENGTH,
|
||||
message: `Keep the title under ${TITLE_MAX_LENGTH} characters.`,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.title ? (
|
||||
<p className="field-error" id="task-title-error" role="alert">
|
||||
{errors.title.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="form-hint">
|
||||
<span className="required-marker" aria-hidden="true">
|
||||
*
|
||||
</span>{' '}
|
||||
Required field
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="task-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
className="form-textarea"
|
||||
rows={3}
|
||||
placeholder="Enter detailed task description..."
|
||||
aria-invalid={errors.description ? 'true' : 'false'}
|
||||
aria-describedby={errors.description ? 'task-description-error' : undefined}
|
||||
{...register('description', {
|
||||
maxLength: {
|
||||
value: DESCRIPTION_MAX_LENGTH,
|
||||
message: `Keep the description under ${DESCRIPTION_MAX_LENGTH} characters.`,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="field-error" id="task-description-error" role="alert">
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label required" htmlFor="task-assignee">
|
||||
Assignee
|
||||
</label>
|
||||
<select
|
||||
id="task-assignee"
|
||||
className="form-select"
|
||||
aria-required="true"
|
||||
aria-invalid={errors.assignee_id ? 'true' : 'false'}
|
||||
aria-describedby={errors.assignee_id ? 'task-assignee-error' : undefined}
|
||||
{...register('assignee_id', {
|
||||
required: 'Choose the team member who owns this task.',
|
||||
})}
|
||||
>
|
||||
<option value="">Select assignee...</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.assignee_id && (
|
||||
<p className="field-error" id="task-assignee-error" role="alert">
|
||||
{errors.assignee_id.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset className="form-group status-fieldset">
|
||||
<legend className="form-label required">Status</legend>
|
||||
<div className="status-buttons">
|
||||
{TASK_STATUSES.map((status) => (
|
||||
<label key={status} className="status-option">
|
||||
<input type="radio" value={status} {...register('status')} />
|
||||
<span className={`status-pill ${status}`}>{STATUS_LABELS[status]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label required" htmlFor="task-due-date">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
className="form-input"
|
||||
aria-required="true"
|
||||
aria-invalid={errors.due_date ? 'true' : 'false'}
|
||||
aria-describedby={errors.due_date ? 'task-due-date-error' : undefined}
|
||||
{...register('due_date', { required: 'Pick a due date for this task.' })}
|
||||
/>
|
||||
{errors.due_date && (
|
||||
<p className="field-error" id="task-due-date-error" role="alert">
|
||||
{errors.due_date.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label required" htmlFor="task-priority">
|
||||
Priority
|
||||
</label>
|
||||
<select id="task-priority" className="form-select" {...register('priority')}>
|
||||
{TASK_PRIORITIES.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{PRIORITY_LABELS[priority]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Task'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskForm;
|
||||
@ -1,90 +1,68 @@
|
||||
import React from " react\;
|
||||
import { Task } from \../../types\;
|
||||
import { format } from \date-fns\;
|
||||
import React from 'react';
|
||||
import { Task, TaskStatus } from '../../types';
|
||||
import { AssigneeLabel, DueDate, PriorityIndicator, StatusSelect } from '../common/TaskFields';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
busy: boolean;
|
||||
onEdit: (task: Task) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
onDelete: (task: Task) => void;
|
||||
onStatusChange: (task: Task, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDelete }) => {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return \No due date\;
|
||||
try {
|
||||
return format(new Date(dateString), \MMM dd\);
|
||||
} catch {
|
||||
return \Invalid date\;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name: string) => {
|
||||
return name.split(" \).map(n => n[0]).join(\).toUpperCase();
|
||||
};
|
||||
|
||||
const isOverdue = task.due_date && new Date(task.due_date) < new Date();
|
||||
|
||||
return (
|
||||
<div className=\task-card\>
|
||||
<div className=\task-card-header\>
|
||||
<div className=\task-meta\>
|
||||
<span className={status-badge }>
|
||||
{task.status.replace(\_\, " \)}
|
||||
</span>
|
||||
<span className={priority-indicator }>
|
||||
{task.priority}
|
||||
</span>
|
||||
/** Mobile card view, per the approved mobile dashboard mockup. */
|
||||
const TaskCard: React.FC<TaskCardProps> = ({ task, busy, onEdit, onDelete, onStatusChange }) => (
|
||||
<article className="task-card" aria-label={task.title}>
|
||||
<div className="task-card-header">
|
||||
<div className="task-meta">
|
||||
<PriorityIndicator priority={task.priority} />
|
||||
<StatusSelect
|
||||
status={task.status}
|
||||
taskTitle={task.title}
|
||||
disabled={busy}
|
||||
onChange={(status) => onStatusChange(task, status)}
|
||||
/>
|
||||
</div>
|
||||
<div className=\task-actions-mobile\>
|
||||
<div className="task-actions-mobile">
|
||||
<button
|
||||
className=\btn-action edit onClick={() => onEdit(task)}
|
||||
aria-label={Edit }
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => onEdit(task)}
|
||||
disabled={busy}
|
||||
aria-label={`Edit ${task.title}`}
|
||||
>
|
||||
✏️
|
||||
<span aria-hidden="true">✎</span>
|
||||
</button>
|
||||
<button
|
||||
className=\btn-action delete onClick={() => onDelete(task.id)}
|
||||
aria-label={Delete }
|
||||
type="button"
|
||||
className="btn-action delete"
|
||||
onClick={() => onDelete(task)}
|
||||
disabled={busy}
|
||||
aria-label={`Delete ${task.title}`}
|
||||
>
|
||||
🗑️
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=\task-card-content\>
|
||||
<button
|
||||
className=\task-card-title-btn onClick={() => onEdit(task)}
|
||||
type="button"
|
||||
className="task-card-title-btn"
|
||||
onClick={() => onEdit(task)}
|
||||
aria-label={`Open ${task.title}`}
|
||||
>
|
||||
<h3 className=\task-card-title\>{task.title}</h3>
|
||||
<h3 className="task-card-title">{task.title}</h3>
|
||||
</button>
|
||||
|
||||
{task.description && (
|
||||
<p className=\task-card-description\>{task.description}</p>
|
||||
)}
|
||||
{task.description && <p className="task-card-description">{task.description}</p>}
|
||||
|
||||
<div className=\task-card-footer\>
|
||||
<div className=\assignee-info\>
|
||||
{task.assigned_to ? (
|
||||
<>
|
||||
<div className=\user-avatar sm\>
|
||||
{getUserInitials(task.assigned_to.full_name)}
|
||||
</div>
|
||||
<span className=\assignee-name\>{task.assigned_to.full_name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className=\unassigned\>Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className=\due-date-info\>
|
||||
<span className={due-date }>
|
||||
{formatDate(task.due_date)}
|
||||
<div className="task-card-footer">
|
||||
<AssigneeLabel user={task.assigned_to} />
|
||||
<span className="due-date-info">
|
||||
<DueDate value={task.due_date} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</article>
|
||||
);
|
||||
|
||||
export default TaskCard;
|
||||
|
||||
@ -1,55 +1,56 @@
|
||||
/* TaskList Container */
|
||||
.task-list-container {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
/* Toolbar: segmented filter tabs on the left, Create Task on the right */
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background-color: #f8fafc;
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background-color: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #374151;
|
||||
color: #334155;
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background-color: white;
|
||||
color: #3b82f6;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.filter-tab:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
border-color: #e2e8f0;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: #e2e8f0;
|
||||
color: #334155;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@ -58,8 +59,8 @@
|
||||
}
|
||||
|
||||
.filter-count.blocked {
|
||||
background-color: #fecaca;
|
||||
color: #991b1b;
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.filter-tab.active .filter-count {
|
||||
@ -67,56 +68,105 @@
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.task-list-empty,
|
||||
.blocked-empty-state {
|
||||
.filter-tab.active .filter-count.blocked {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Guidance for the blocked view, per the blocked-state mockup */
|
||||
.blocked-notice {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background-color: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 0.5rem;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.blocked-notice-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.blocked-notice h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.blocked-notice p {
|
||||
font-size: 0.875rem;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Task list card */
|
||||
.task-list-container {
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
.task-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
max-width: 400px;
|
||||
max-width: 26rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-icon,
|
||||
.blocked-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.task-list-empty h2,
|
||||
.blocked-empty-state h2 {
|
||||
font-size: 1.5rem;
|
||||
.task-list-empty h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.task-list-empty p,
|
||||
.blocked-empty-state p {
|
||||
.task-list-empty p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Desktop Table View */
|
||||
/* Desktop table. Column order matches the approved dashboard mockup,
|
||||
with an actions column added for edit and delete. */
|
||||
.task-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-table-header {
|
||||
.task-table-header,
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 120px 100px 120px 80px;
|
||||
grid-template-columns: 108px minmax(0, 2fr) 150px 132px 128px 104px;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-table-header {
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-table-body {
|
||||
@ -125,10 +175,6 @@
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 120px 100px 120px 80px;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
@ -144,42 +190,44 @@
|
||||
.task-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Task Title */
|
||||
.task-cell.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Task title */
|
||||
.task-title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-title-btn {
|
||||
.task-title-btn,
|
||||
.task-card-title-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-title-btn:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
font-family: inherit;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-title-btn:hover .task-title {
|
||||
color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@ -193,169 +241,120 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Assignee */
|
||||
.assignee-info {
|
||||
.task-actions,
|
||||
.task-actions-mobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.assignee-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.unassigned {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Due Date */
|
||||
.due-date.overdue {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-action:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-action.delete:hover {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
/* Mobile Card View */
|
||||
/* Mobile card view */
|
||||
.task-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: #d1d5db;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 2px 0 rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-actions-mobile {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.task-card-title-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-card-title-btn:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-card-title-btn:hover .task-card-title {
|
||||
color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.task-card-description {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.task-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.due-date-info .due-date {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
/*
|
||||
* One breakpoint decides which view is visible. Both are in the DOM, but
|
||||
* display:none also removes the hidden one from the accessibility tree, so
|
||||
* screen readers never hear a task twice.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.task-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-list-container {
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Create Task becomes a floating action button, as in the mobile mockup. */
|
||||
.create-task-btn {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 10px -1px rgba(15, 23, 42, 0.3);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.create-task-btn .create-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.create-task-btn .create-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -364,13 +363,3 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.task-row,
|
||||
.task-card,
|
||||
.filter-tab,
|
||||
.btn-action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,147 +1,222 @@
|
||||
import React, { useState } from " react\;
|
||||
import { Task, TaskFilter } from \../../types\;
|
||||
import TaskRow from \./TaskRow\;
|
||||
import TaskCard from \./TaskCard\;
|
||||
import \./TaskList.css\;
|
||||
import React, { useRef } from 'react';
|
||||
import { Task, TaskFilter, TaskStatus } from '../../types';
|
||||
import TaskCard from './TaskCard';
|
||||
import TaskRow from './TaskRow';
|
||||
import './TaskList.css';
|
||||
|
||||
export interface FilterCounts {
|
||||
my: number;
|
||||
all: number;
|
||||
blocked: number;
|
||||
}
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
totalTaskCount: number;
|
||||
counts: FilterCounts;
|
||||
currentFilter: TaskFilter;
|
||||
pendingTaskId: number | null;
|
||||
onFilterChange: (filter: TaskFilter) => void;
|
||||
onEditTask: (task: Task) => void;
|
||||
onDeleteTask: (taskId: number) => void;
|
||||
onCreateTask: () => void;
|
||||
onEditTask: (task: Task) => void;
|
||||
onDeleteTask: (task: Task) => void;
|
||||
onStatusChange: (task: Task, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
const FILTERS: { value: TaskFilter; label: string }[] = [
|
||||
{ value: 'my', label: 'My Tasks' },
|
||||
{ value: 'all', label: 'All Tasks' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
const COLUMNS = ['Priority', 'Task Name', 'Assignee', 'Status', 'Due Date', 'Actions'];
|
||||
|
||||
const EMPTY_STATES: Record<TaskFilter, { icon: string; heading: string; body: string }> = {
|
||||
my: {
|
||||
icon: '\u2705',
|
||||
heading: 'Nothing assigned to you',
|
||||
body: 'When a teammate assigns you a task it shows up here.',
|
||||
},
|
||||
all: {
|
||||
icon: '\u{1F4CB}',
|
||||
heading: 'No tasks yet',
|
||||
body: 'Get started by creating your first task.',
|
||||
},
|
||||
blocked: {
|
||||
icon: '\u{1F6AB}',
|
||||
heading: 'No blocked tasks',
|
||||
body: 'Your team has no blocked work right now.',
|
||||
},
|
||||
};
|
||||
|
||||
const TaskList: React.FC<TaskListProps> = ({
|
||||
tasks,
|
||||
totalTaskCount,
|
||||
counts,
|
||||
currentFilter,
|
||||
pendingTaskId,
|
||||
onFilterChange,
|
||||
onCreateTask,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onCreateTask
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<\desktop\ | \mobile\>(\desktop\);
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate filter counts
|
||||
const filterCounts = {
|
||||
my: tasks.filter(t => t.assigned_to?.id === 1).length, // Mock current user ID
|
||||
all: tasks.length,
|
||||
blocked: tasks.filter(t => t.status === \blocked\).length
|
||||
// Left/Right arrows move between filter tabs, as expected of an ARIA tablist.
|
||||
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = FILTERS.findIndex((filter) => filter.value === currentFilter);
|
||||
const step = event.key === 'ArrowRight' ? 1 : -1;
|
||||
const next = FILTERS[(currentIndex + step + FILTERS.length) % FILTERS.length];
|
||||
|
||||
onFilterChange(next.value);
|
||||
tabsRef.current?.querySelector<HTMLButtonElement>(`#filter-tab-${next.value}`)?.focus();
|
||||
};
|
||||
|
||||
// Detect mobile view
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewMode(window.innerWidth <= 768 ? \mobile\ : \desktop\);
|
||||
};
|
||||
const emptyState = EMPTY_STATES[totalTaskCount === 0 ? 'all' : currentFilter];
|
||||
|
||||
handleResize();
|
||||
window.addEventListener(\resize\, handleResize);
|
||||
return () => window.removeEventListener(\resize\, handleResize);
|
||||
}, []);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className=\task-list-empty\>
|
||||
<div className=\empty-state\>
|
||||
<div className=\empty-icon\>📋</div>
|
||||
<h2>No tasks yet</h2>
|
||||
<p>Get started by creating your first task</p>
|
||||
<button
|
||||
className=\btn btn-primary onClick={onCreateTask}
|
||||
<div className="task-list-shell">
|
||||
<div className="list-toolbar">
|
||||
<div
|
||||
className="filter-tabs"
|
||||
role="tablist"
|
||||
aria-label="Filter tasks"
|
||||
ref={tabsRef}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
>
|
||||
{FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
id={`filter-tab-${filter.value}`}
|
||||
role="tab"
|
||||
className={`filter-tab${currentFilter === filter.value ? ' active' : ''}`}
|
||||
aria-selected={currentFilter === filter.value}
|
||||
aria-controls="task-panel"
|
||||
tabIndex={currentFilter === filter.value ? 0 : -1}
|
||||
/* The count badge is hidden from the name and folded into the
|
||||
label, otherwise the name reads "All Tasks , 2 tasks". */
|
||||
aria-label={`${filter.label}, ${counts[filter.value]} ${
|
||||
counts[filter.value] === 1 ? 'task' : 'tasks'
|
||||
}`}
|
||||
onClick={() => onFilterChange(filter.value)}
|
||||
>
|
||||
{filter.label}
|
||||
<span
|
||||
className={`filter-count${filter.value === 'blocked' ? ' blocked' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{counts[filter.value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* aria-label keeps the name stable when the label is hidden in the mobile FAB. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary create-task-btn"
|
||||
onClick={onCreateTask}
|
||||
aria-label="Create task"
|
||||
>
|
||||
<span className="create-icon" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="create-text">Create Task</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="task-panel"
|
||||
id="task-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`filter-tab-${currentFilter}`}
|
||||
>
|
||||
{currentFilter === 'blocked' && tasks.length > 0 && (
|
||||
<div className="blocked-notice">
|
||||
<span className="blocked-notice-icon" aria-hidden="true">
|
||||
⚠
|
||||
</span>
|
||||
<div>
|
||||
<h3>These tasks are blocked</h3>
|
||||
<p>
|
||||
Blocked work cannot move forward yet, usually because it depends on another
|
||||
unfinished task or is waiting on access. Open a task to review the details, then
|
||||
reach out to the assignee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="task-list-empty">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon" aria-hidden="true">
|
||||
{emptyState.icon}
|
||||
</div>
|
||||
<h2>{emptyState.heading}</h2>
|
||||
<p>{emptyState.body}</p>
|
||||
{totalTaskCount === 0 && (
|
||||
<button type="button" className="btn btn-primary" onClick={onCreateTask}>
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=\task-list-container\>
|
||||
{/* Filter Tabs */}
|
||||
<div className=\filter-tabs\>
|
||||
<button
|
||||
className={ilter-tab }
|
||||
onClick={() => onFilterChange(\my\)}
|
||||
aria-pressed={currentFilter === \my\}
|
||||
>
|
||||
My Tasks
|
||||
{filterCounts.my > 0 && (
|
||||
<span className=\filter-count\>{filterCounts.my}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={ilter-tab }
|
||||
onClick={() => onFilterChange(\all\)}
|
||||
aria-pressed={currentFilter === \all\}
|
||||
>
|
||||
All Tasks
|
||||
{filterCounts.all > 0 && (
|
||||
<span className=\filter-count\>{filterCounts.all}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={ilter-tab }
|
||||
onClick={() => onFilterChange(\blocked\)}
|
||||
aria-pressed={currentFilter === \blocked\}
|
||||
>
|
||||
Blocked
|
||||
{filterCounts.blocked > 0 && (
|
||||
<span className=\filter-count blocked\>{filterCounts.blocked}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className=\task-list\>
|
||||
{viewMode === \desktop\ ? (
|
||||
// Desktop Table View
|
||||
<div className=\task-table\>
|
||||
<div className=\task-table-header\>
|
||||
<div className=\header-cell task-name\>Task Name</div>
|
||||
<div className=\header-cell assignee\>Assignee</div>
|
||||
<div className=\header-cell status\>Status</div>
|
||||
<div className=\header-cell priority\>Priority</div>
|
||||
<div className=\header-cell due-date\>Due Date</div>
|
||||
<div className=\header-cell actions\>Actions</div>
|
||||
</div>
|
||||
<div className=\task-table-body\>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Mobile Card View
|
||||
<div className=\task-cards\>
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
<div className="task-list-container">
|
||||
{/* Both views are rendered; CSS media queries decide which one is shown,
|
||||
so only one reaches the accessibility tree at any width. */}
|
||||
<div className="task-table" role="table" aria-label="Tasks">
|
||||
<div className="task-table-header" role="row">
|
||||
{COLUMNS.map((column) => (
|
||||
<div
|
||||
key={column}
|
||||
className={`header-cell ${column.toLowerCase().replace(' ', '-')}`}
|
||||
role="columnheader"
|
||||
>
|
||||
{/* The mockup leaves the priority heading blank. Hiding the
|
||||
text (not the cell) keeps the grid columns aligned. */}
|
||||
<span className={column === 'Priority' ? 'sr-only' : undefined}>{column}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="task-table-body" role="rowgroup">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={pendingTaskId === task.id}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Special handling for blocked tasks */}
|
||||
{currentFilter === \blocked\ && tasks.length === 0 && (
|
||||
<div className=\blocked-empty-state\>
|
||||
<div className=\blocked-icon\>🚫</div>
|
||||
<h2>No blocked tasks</h2>
|
||||
<p>Great! Your team has no blocked work right now.</p>
|
||||
<div className="task-cards">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={pendingTaskId === task.id}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,102 +1,78 @@
|
||||
import React from " react\;
|
||||
import { Task } from \../../types\;
|
||||
import { format } from \date-fns\;
|
||||
import React from 'react';
|
||||
import { Task, TaskStatus } from '../../types';
|
||||
import { AssigneeLabel, DueDate, PriorityIndicator, StatusSelect } from '../common/TaskFields';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
busy: boolean;
|
||||
onEdit: (task: Task) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
onDelete: (task: Task) => void;
|
||||
onStatusChange: (task: Task, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = ({ task, onEdit, onDelete }) => {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return \No due date\;
|
||||
try {
|
||||
return format(new Date(dateString), \MMM dd yyyy\);
|
||||
} catch {
|
||||
return \Invalid date\;
|
||||
}
|
||||
};
|
||||
/** Desktop table row. Column order follows the approved dashboard mockup. */
|
||||
const TaskRow: React.FC<TaskRowProps> = ({ task, busy, onEdit, onDelete, onStatusChange }) => (
|
||||
<div className="task-row" role="row">
|
||||
<div className="task-cell priority" role="cell">
|
||||
<PriorityIndicator priority={task.priority} />
|
||||
</div>
|
||||
|
||||
const getUserInitials = (name: string) => {
|
||||
return name.split(" \).map(n => n[0]).join(\).toUpperCase();
|
||||
};
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent, action: () => void) => {
|
||||
if (event.key === \Enter\ || event.key === " \) {
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=\task-row\ role=\row\>
|
||||
<div className=\task-cell task-name\ role=\gridcell\>
|
||||
<div className=\task-title-container\>
|
||||
<div className="task-cell task-name" role="cell">
|
||||
<div className="task-title-container">
|
||||
{/* Distinct name from the pencil button so the two controls are
|
||||
tellable apart in a screen reader's list of links and buttons. */}
|
||||
<button
|
||||
className=\task-title-btn onClick={() => onEdit(task)}
|
||||
onKeyDown={(e) => handleKeyPress(e, () => onEdit(task))}
|
||||
aria-label={Edit task: }
|
||||
type="button"
|
||||
className="task-title-btn"
|
||||
onClick={() => onEdit(task)}
|
||||
aria-label={`Open ${task.title}`}
|
||||
>
|
||||
<span className=\task-title\>{task.title}</span>
|
||||
<span className="task-title">{task.title}</span>
|
||||
</button>
|
||||
{task.description && (
|
||||
<span className=\task-description\>{task.description}</span>
|
||||
)}
|
||||
{task.description && <span className="task-description">{task.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=\task-cell assignee\ role=\gridcell\>
|
||||
{task.assigned_to ? (
|
||||
<div className=\assignee-info\>
|
||||
<div className=\user-avatar sm\>
|
||||
{getUserInitials(task.assigned_to.full_name)}
|
||||
</div>
|
||||
<span className=\assignee-name\>{task.assigned_to.full_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className=\unassigned\>Unassigned</span>
|
||||
)}
|
||||
<div className="task-cell assignee" role="cell">
|
||||
<AssigneeLabel user={task.assigned_to} />
|
||||
</div>
|
||||
|
||||
<div className=\task-cell status\ role=\gridcell\>
|
||||
<span className={status-badge }>
|
||||
{task.status.replace(\_\, " \)}
|
||||
</span>
|
||||
<div className="task-cell status" role="cell">
|
||||
<StatusSelect
|
||||
status={task.status}
|
||||
taskTitle={task.title}
|
||||
disabled={busy}
|
||||
onChange={(status) => onStatusChange(task, status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className=\task-cell priority\ role=\gridcell\>
|
||||
<span className={priority-indicator }>
|
||||
{task.priority}
|
||||
</span>
|
||||
<div className="task-cell due-date-cell" role="cell">
|
||||
<DueDate value={task.due_date} />
|
||||
</div>
|
||||
|
||||
<div className=\task-cell due-date\ role=\gridcell\>
|
||||
<span className={due-date }>
|
||||
{formatDate(task.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className=\task-cell actions\ role=\gridcell\>
|
||||
<div className=\task-actions\>
|
||||
<div className="task-cell actions" role="cell">
|
||||
<div className="task-actions">
|
||||
<button
|
||||
className=\btn-action edit onClick={() => onEdit(task)}
|
||||
onKeyDown={(e) => handleKeyPress(e, () => onEdit(task))}
|
||||
aria-label={Edit }
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => onEdit(task)}
|
||||
disabled={busy}
|
||||
aria-label={`Edit ${task.title}`}
|
||||
>
|
||||
✏️
|
||||
<span aria-hidden="true">✎</span>
|
||||
</button>
|
||||
<button
|
||||
className=\btn-action delete onClick={() => onDelete(task.id)}
|
||||
onKeyDown={(e) => handleKeyPress(e, () => onDelete(task.id))}
|
||||
aria-label={Delete }
|
||||
type="button"
|
||||
className="btn-action delete"
|
||||
onClick={() => onDelete(task)}
|
||||
disabled={busy}
|
||||
aria-label={`Delete ${task.title}`}
|
||||
>
|
||||
🗑️
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export default TaskRow;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/* AppShell Layout */
|
||||
/* AppShell layout: header, team sidebar, main region */
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@ -10,7 +10,7 @@
|
||||
.app-header {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
@ -19,17 +19,16 @@
|
||||
.app-header-content {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.875rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-logo h1 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
@ -38,30 +37,19 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: #334155;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Body Layout */
|
||||
/* Body */
|
||||
.app-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -72,12 +60,11 @@
|
||||
|
||||
/* Sidebar */
|
||||
.app-sidebar {
|
||||
width: 280px;
|
||||
width: 264px;
|
||||
flex: none;
|
||||
background-color: white;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
@ -85,24 +72,25 @@
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.team-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.team-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.2s;
|
||||
@ -116,38 +104,33 @@
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.online-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #10b981;
|
||||
background-color: #059669;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
/* Main */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
background-color: #f8fafc;
|
||||
min-width: 0;
|
||||
padding: 1.75rem 2rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-header-content {
|
||||
padding: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.app-logo h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.create-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
@ -156,37 +139,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Team list is secondary on small screens; the task list gets the room. */
|
||||
.app-sidebar {
|
||||
display: none; /* Hidden on mobile - could be toggle later */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Mobile floating action button */
|
||||
.create-task-btn {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.create-task-btn .create-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-header-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 0.75rem;
|
||||
padding-bottom: 5.5rem; /* clear of the floating Create Task button */
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,71 +1,59 @@
|
||||
import React from " react\;
|
||||
import { User } from \../../types\;
|
||||
import \./AppShell.css\;
|
||||
import React from 'react';
|
||||
import { User } from '../../types';
|
||||
import { UserAvatar } from './TaskFields';
|
||||
import './AppShell.css';
|
||||
|
||||
interface AppShellProps {
|
||||
currentUser: User;
|
||||
users: User[];
|
||||
children: React.ReactNode;
|
||||
onCreateTask: () => void;
|
||||
}
|
||||
|
||||
const AppShell: React.FC<AppShellProps> = ({
|
||||
currentUser,
|
||||
users,
|
||||
children,
|
||||
onCreateTask
|
||||
}) => {
|
||||
return (
|
||||
<div className=\app-shell\>
|
||||
{/* Header */}
|
||||
<header className=\app-header\>
|
||||
<div className=\app-header-content\>
|
||||
<div className=\app-logo\>
|
||||
/** Header + team sidebar + main region, per the approved desktop dashboard mockup. */
|
||||
const AppShell: React.FC<AppShellProps> = ({ currentUser, users, children }) => (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">
|
||||
Skip to task list
|
||||
</a>
|
||||
|
||||
<header className="app-header">
|
||||
<div className="app-header-content">
|
||||
<div className="app-logo">
|
||||
<h1>TaskFlow</h1>
|
||||
</div>
|
||||
<div className=\app-header-actions\>
|
||||
<button
|
||||
className=\btn btn-primary create-task-btn onClick={onCreateTask}
|
||||
aria-label=\Create new task >
|
||||
<span className=\create-icon\>+</span>
|
||||
<span className=\create-text\>Create Task</span>
|
||||
</button>
|
||||
<div className=\user-profile\>
|
||||
<div className=\user-avatar lg\>
|
||||
{currentUser.full_name.split(" \).map(n => n[0]).join(\)}
|
||||
</div>
|
||||
<span className=\user-name\>{currentUser.full_name}</span>
|
||||
<div className="app-header-actions">
|
||||
<div className="user-profile">
|
||||
<UserAvatar user={currentUser} size="lg" />
|
||||
<span className="user-name">{currentUser.full_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className=\app-body\>
|
||||
{/* Sidebar - Desktop only */}
|
||||
<aside className=\app-sidebar\>
|
||||
<div className=\sidebar-section\>
|
||||
<h3 className=\sidebar-heading\>Team Members</h3>
|
||||
<div className=\team-list\>
|
||||
{users.map(user => (
|
||||
<div key={user.id} className=\team-member\>
|
||||
<div className=\user-avatar sm\>
|
||||
{user.full_name.split(" \).map(n => n[0]).join(\)}
|
||||
</div>
|
||||
<span className=\member-name\>{user.full_name}</span>
|
||||
<div className=\online-indicator\></div>
|
||||
</div>
|
||||
<div className="app-body">
|
||||
<aside className="app-sidebar" aria-labelledby="team-heading">
|
||||
<div className="sidebar-section">
|
||||
<h2 className="sidebar-heading" id="team-heading">
|
||||
Team Members
|
||||
</h2>
|
||||
<ul className="team-list">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="team-member">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<span className="member-name">{user.full_name}</span>
|
||||
<span className="online-indicator" aria-hidden="true" />
|
||||
<span className="sr-only">Online</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className=\app-main\>
|
||||
<main className="app-main" id="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export default AppShell;
|
||||
|
||||
44
frontend/src/components/common/ErrorBoundary.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last line of defence: a render crash shows a recovery screen instead of a
|
||||
* blank page, so a user never loses sight of their work without an explanation.
|
||||
*/
|
||||
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): ErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// Kept in the console for now; a real logging endpoint is a Backend concern.
|
||||
console.error('TaskFlow crashed while rendering', error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="app-error" role="alert">
|
||||
<h1>TaskFlow ran into a problem</h1>
|
||||
<p>Nothing was lost. Reload the page to pick up where you left off.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload TaskFlow
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
110
frontend/src/components/common/TaskFields.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { format, isBefore, parseISO, startOfToday } from 'date-fns';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
STATUS_LABELS,
|
||||
TASK_STATUSES,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
User,
|
||||
} from '../../types';
|
||||
|
||||
/** Initials avatar. Falls back to a neutral glyph when a task is unassigned. */
|
||||
export const UserAvatar: React.FC<{ user?: User; size?: 'sm' | 'md' | 'lg' }> = ({
|
||||
user,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const initials = user
|
||||
? user.full_name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: '?';
|
||||
|
||||
return (
|
||||
<span className={`user-avatar ${size}`} aria-hidden="true">
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Coloured dot plus label, matching the priority column in the approved mockup. */
|
||||
export const PriorityIndicator: React.FC<{ priority: TaskPriority }> = ({ priority }) => (
|
||||
<span className={`priority-indicator ${priority}`}>
|
||||
<span className="priority-dot" aria-hidden="true" />
|
||||
{PRIORITY_LABELS[priority]}
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Status pill that is also the control for changing status. A native select keeps
|
||||
* keyboard and screen reader behaviour correct for free.
|
||||
*/
|
||||
export const StatusSelect: React.FC<{
|
||||
status: TaskStatus;
|
||||
taskTitle: string;
|
||||
disabled?: boolean;
|
||||
onChange: (status: TaskStatus) => void;
|
||||
}> = ({ status, taskTitle, disabled = false, onChange }) => (
|
||||
<span className={`status-select-wrap ${status}`}>
|
||||
<select
|
||||
className={`status-select ${status}`}
|
||||
value={status}
|
||||
disabled={disabled}
|
||||
aria-label={`Status for ${taskTitle}`}
|
||||
onChange={(event) => onChange(event.target.value as TaskStatus)}
|
||||
>
|
||||
{TASK_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{STATUS_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Assignee cell: avatar plus shortened name, or an explicit unassigned state. */
|
||||
export const AssigneeLabel: React.FC<{ user?: User }> = ({ user }) => {
|
||||
if (!user) {
|
||||
return <span className="unassigned">Unassigned</span>;
|
||||
}
|
||||
|
||||
const [first, ...rest] = user.full_name.split(' ');
|
||||
const last = rest.length > 0 ? `${rest[rest.length - 1][0]}.` : '';
|
||||
|
||||
return (
|
||||
<span className="assignee-info">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<span className="assignee-name">{`${first} ${last}`.trim()}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const parseDue = (value?: string): Date | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseISO(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
/** Due date in the mockup's "Oct 15, 2023" format, flagged when overdue. */
|
||||
export const DueDate: React.FC<{ value?: string }> = ({ value }) => {
|
||||
const date = parseDue(value);
|
||||
|
||||
if (!date) {
|
||||
return <span className="due-date none">No due date</span>;
|
||||
}
|
||||
|
||||
const overdue = isBefore(date, startOfToday());
|
||||
|
||||
return (
|
||||
<span className={`due-date${overdue ? ' overdue' : ''}`}>
|
||||
<time dateTime={value}>{format(date, 'MMM d, yyyy')}</time>
|
||||
{overdue && <span className="sr-only"> (overdue)</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
19
frontend/src/index.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/common/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
if (!container) {
|
||||
throw new Error('TaskFlow could not start: #root is missing from index.html');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -1,13 +0,0 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@ -1,114 +1,259 @@
|
||||
import { Task, User } from " ../types\;
|
||||
import {
|
||||
Task,
|
||||
TaskDraft,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
TITLE_MAX_LENGTH,
|
||||
DESCRIPTION_MAX_LENGTH,
|
||||
User,
|
||||
} from '../types';
|
||||
|
||||
// Mock data for development - will be replaced with real API calls
|
||||
const mockUsers: User[] = [
|
||||
{ id: 1, username: \sarah.chen\, full_name: \Sarah Chen\, avatar_url: \/avatars/sarah.jpg\ },
|
||||
{ id: 2, username: \mike.johnson\, full_name: \Mike Johnson\, avatar_url: \/avatars/mike.jpg\ },
|
||||
{ id: 3, username: \alex.kim\, full_name: \Alex Kim\, avatar_url: \/avatars/alex.jpg\ },
|
||||
{ id: 4, username: \emma.davis\, full_name: \Emma Davis\, avatar_url: \/avatars/emma.jpg\ }
|
||||
/**
|
||||
* In-memory stand-in for the REST API described in
|
||||
* architect/03-feasibility/frontend-handoff.md. Method names and payload shapes
|
||||
* match the documented endpoints so the Backend phase can swap the bodies for
|
||||
* fetch/axios calls without touching any component.
|
||||
*
|
||||
* GET /api/auth/me -> getCurrentUser()
|
||||
* GET /api/users -> getUsers()
|
||||
* GET /api/tasks -> getTasks()
|
||||
* POST /api/tasks -> createTask(draft)
|
||||
* PUT /api/tasks/:id -> updateTask(id, changes)
|
||||
* DELETE /api/tasks/:id -> deleteTask(id)
|
||||
*/
|
||||
|
||||
/** Error type carrying a message that is safe to show a user. */
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const users: User[] = [
|
||||
{ id: 1, username: 'sarah.chen', full_name: 'Sarah Chen' },
|
||||
{ id: 2, username: 'alex.rivera', full_name: 'Alex Rivera' },
|
||||
{ id: 3, username: 'david.kim', full_name: 'David Kim' },
|
||||
{ id: 4, username: 'maria.garcia', full_name: 'Maria Garcia' },
|
||||
{ id: 5, username: 'ben.carter', full_name: 'Ben Carter' },
|
||||
];
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
/** Signed-in user. Real implementation reads this from GET /api/auth/me. */
|
||||
const currentUser = users[0];
|
||||
|
||||
const isoDate = (dayOffset: number): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + dayOffset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const timestamp = (dayOffset: number): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + dayOffset);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
let tasks: Task[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: \Update client documentation\,
|
||||
description: \Review and update onboarding docs for new client portal\,
|
||||
assigned_to: mockUsers[0],
|
||||
status: \in_progress\,
|
||||
priority: \high\,
|
||||
due_date: \2024-09-15\,
|
||||
created_by: mockUsers[1],
|
||||
created_at: \2024-09-01T10:00:00Z\,
|
||||
updated_at: \2024-09-05T14:22:00Z },
|
||||
title: 'Website Redesign - Homepage Layout',
|
||||
description: 'Finalize wireframes and mobile view',
|
||||
assigned_to: users[1],
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
due_date: isoDate(5),
|
||||
created_by: currentUser,
|
||||
created_at: timestamp(-9),
|
||||
updated_at: timestamp(-1),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: \Fix login validation bug\,
|
||||
description: \Users are experiencing issues with password validation on the login form\,
|
||||
assigned_to: mockUsers[2],
|
||||
status: \blocked\,
|
||||
priority: \high\,
|
||||
due_date: \2024-09-10\,
|
||||
created_by: mockUsers[0],
|
||||
created_at: \2024-09-02T09:15:00Z\,
|
||||
updated_at: \2024-09-04T16:30:00Z },
|
||||
title: 'Q4 Marketing Strategy',
|
||||
description: 'Outline key campaigns and budget',
|
||||
assigned_to: users[0],
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
due_date: isoDate(10),
|
||||
created_by: users[2],
|
||||
created_at: timestamp(-7),
|
||||
updated_at: timestamp(-7),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: \Design new dashboard layout\,
|
||||
description: \Create wireframes and mockups for the updated dashboard interface\,
|
||||
assigned_to: mockUsers[3],
|
||||
status: \open\,
|
||||
priority: \medium\,
|
||||
due_date: \2024-09-20\,
|
||||
created_by: mockUsers[1],
|
||||
created_at: \2024-09-03T11:45:00Z\,
|
||||
updated_at: \2024-09-03T11:45:00Z },
|
||||
title: 'API Integration Debugging',
|
||||
description: 'Fixing errors in payment gateway',
|
||||
assigned_to: users[2],
|
||||
status: 'blocked',
|
||||
priority: 'high',
|
||||
due_date: isoDate(8),
|
||||
created_by: currentUser,
|
||||
created_at: timestamp(-6),
|
||||
updated_at: timestamp(-2),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: \Set up automated testing\,
|
||||
description: \Configure CI/CD pipeline with automated test suite\,
|
||||
assigned_to: mockUsers[0],
|
||||
status: \open\,
|
||||
priority: \medium\,
|
||||
due_date: \2024-09-25\,
|
||||
created_by: mockUsers[2],
|
||||
created_at: \2024-09-04T08:20:00Z\,
|
||||
updated_at: \2024-09-04T08:20:00Z }
|
||||
title: 'Partner Onboarding Playbook',
|
||||
description: 'Document the hand-off steps for new partners',
|
||||
assigned_to: users[2],
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
due_date: isoDate(-2),
|
||||
created_by: users[3],
|
||||
created_at: timestamp(-14),
|
||||
updated_at: timestamp(-3),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'API Integration Review',
|
||||
description: 'Second pass over the payment gateway contract',
|
||||
assigned_to: users[0],
|
||||
status: 'open',
|
||||
priority: 'low',
|
||||
due_date: isoDate(6),
|
||||
created_by: users[1],
|
||||
created_at: timestamp(-4),
|
||||
updated_at: timestamp(-4),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Mobile Nav Accessibility Audit',
|
||||
description: 'Keyboard and screen reader pass on the mobile navigation',
|
||||
assigned_to: users[3],
|
||||
status: 'blocked',
|
||||
priority: 'medium',
|
||||
due_date: isoDate(12),
|
||||
created_by: currentUser,
|
||||
created_at: timestamp(-3),
|
||||
updated_at: timestamp(-1),
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate API delay
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const delay = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const taskService = {
|
||||
async getTasks(): Promise<Task[]> {
|
||||
await delay(500);
|
||||
return [...mockTasks];
|
||||
const clone = (task: Task): Task => ({ ...task });
|
||||
|
||||
const nextId = (): number =>
|
||||
tasks.reduce((highest, task) => Math.max(highest, task.id), 0) + 1;
|
||||
|
||||
/**
|
||||
* Validates a draft before it reaches the store. The UI validates too, but this
|
||||
* is the trust boundary the real API will guard, so the rules live here as well.
|
||||
*/
|
||||
const validateDraft = (draft: Partial<TaskDraft>, requireAll: boolean): void => {
|
||||
if (requireAll || draft.title !== undefined) {
|
||||
const title = (draft.title ?? '').trim();
|
||||
if (!title) {
|
||||
throw new ApiError('A task title is required.');
|
||||
}
|
||||
if (title.length > TITLE_MAX_LENGTH) {
|
||||
throw new ApiError(`Keep the title under ${TITLE_MAX_LENGTH} characters.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.description !== undefined && draft.description.length > DESCRIPTION_MAX_LENGTH) {
|
||||
throw new ApiError(`Keep the description under ${DESCRIPTION_MAX_LENGTH} characters.`);
|
||||
}
|
||||
|
||||
if (requireAll || draft.assignee_id !== undefined) {
|
||||
const assignee = users.find((user) => user.id === draft.assignee_id);
|
||||
if (!assignee) {
|
||||
throw new ApiError('Assign this task to a current team member.');
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.status !== undefined && !TASK_STATUSES.includes(draft.status)) {
|
||||
throw new ApiError('Choose a valid status.');
|
||||
}
|
||||
|
||||
if (draft.priority !== undefined && !TASK_PRIORITIES.includes(draft.priority)) {
|
||||
throw new ApiError('Choose a valid priority.');
|
||||
}
|
||||
|
||||
if (draft.due_date) {
|
||||
if (Number.isNaN(Date.parse(draft.due_date))) {
|
||||
throw new ApiError('Enter a due date in YYYY-MM-DD format.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const api = {
|
||||
async getCurrentUser(): Promise<User> {
|
||||
await delay(150);
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
async getUsers(): Promise<User[]> {
|
||||
await delay(300);
|
||||
return [...mockUsers];
|
||||
await delay(200);
|
||||
return users.map((user) => ({ ...user }));
|
||||
},
|
||||
|
||||
async createTask(taskData: Partial<Task>): Promise<Task> {
|
||||
async getTasks(): Promise<Task[]> {
|
||||
await delay(400);
|
||||
const newTask: Task = {
|
||||
id: Math.max(...mockTasks.map(t => t.id)) + 1,
|
||||
title: taskData.title || \,
|
||||
description: taskData.description || \,
|
||||
assigned_to: taskData.assigned_to,
|
||||
status: taskData.status || \open\,
|
||||
priority: taskData.priority || \medium\,
|
||||
due_date: taskData.due_date,
|
||||
created_by: mockUsers[0], // Current user
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
return tasks.map(clone);
|
||||
},
|
||||
|
||||
async createTask(draft: TaskDraft): Promise<Task> {
|
||||
await delay(350);
|
||||
validateDraft(draft, true);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const task: Task = {
|
||||
id: nextId(),
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim(),
|
||||
assigned_to: users.find((user) => user.id === draft.assignee_id),
|
||||
status: draft.status,
|
||||
priority: draft.priority,
|
||||
due_date: draft.due_date || undefined,
|
||||
created_by: { ...currentUser },
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockTasks.push(newTask);
|
||||
return newTask;
|
||||
|
||||
tasks = [task, ...tasks];
|
||||
return clone(task);
|
||||
},
|
||||
|
||||
async updateTask(id: number, taskData: Partial<Task>): Promise<Task> {
|
||||
await delay(400);
|
||||
const taskIndex = mockTasks.findIndex(t => t.id === id);
|
||||
if (taskIndex === -1) {
|
||||
throw new Error(\Task not found\);
|
||||
async updateTask(id: number, changes: Partial<TaskDraft>): Promise<Task> {
|
||||
await delay(300);
|
||||
const existing = tasks.find((task) => task.id === id);
|
||||
if (!existing) {
|
||||
throw new ApiError('That task no longer exists. Refresh to see the latest list.', 404);
|
||||
}
|
||||
|
||||
const updatedTask = {
|
||||
...mockTasks[taskIndex],
|
||||
...taskData,
|
||||
updated_at: new Date().toISOString()
|
||||
validateDraft(changes, false);
|
||||
|
||||
const updated: Task = {
|
||||
...existing,
|
||||
title: changes.title !== undefined ? changes.title.trim() : existing.title,
|
||||
description:
|
||||
changes.description !== undefined ? changes.description.trim() : existing.description,
|
||||
assigned_to:
|
||||
changes.assignee_id !== undefined
|
||||
? users.find((user) => user.id === changes.assignee_id)
|
||||
: existing.assigned_to,
|
||||
status: changes.status ?? existing.status,
|
||||
priority: changes.priority ?? existing.priority,
|
||||
due_date: changes.due_date !== undefined ? changes.due_date || undefined : existing.due_date,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mockTasks[taskIndex] = updatedTask;
|
||||
return updatedTask;
|
||||
|
||||
tasks = tasks.map((task) => (task.id === id ? updated : task));
|
||||
return clone(updated);
|
||||
},
|
||||
|
||||
async deleteTask(id: number): Promise<void> {
|
||||
await delay(300);
|
||||
const taskIndex = mockTasks.findIndex(t => t.id === id);
|
||||
if (taskIndex === -1) {
|
||||
throw new Error(\Task not found\);
|
||||
}
|
||||
mockTasks.splice(taskIndex, 1);
|
||||
await delay(250);
|
||||
const exists = tasks.some((task) => task.id === id);
|
||||
if (!exists) {
|
||||
throw new ApiError('That task no longer exists. Refresh to see the latest list.', 404);
|
||||
}
|
||||
tasks = tasks.filter((task) => task.id !== id);
|
||||
},
|
||||
};
|
||||
|
||||
export type Api = typeof api;
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
2
frontend/src/setupTests.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Adds jest-dom matchers (toBeInTheDocument, toBeDisabled, ...) to every test file.
|
||||
import '@testing-library/jest-dom';
|
||||
@ -2,20 +2,50 @@ export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
avatar_url: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'blocked';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
export type TaskFilter = 'my' | 'all' | 'blocked';
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
assigned_to?: User;
|
||||
status: " open\ | \in_progress\ | \blocked\;
|
||||
priority: \low\ | \medium\ | \high\;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
due_date?: string;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type TaskFilter = \my\ | \all\ | \blocked\;
|
||||
/** Shape the task form submits. Mirrors the POST/PUT /api/tasks request body. */
|
||||
export interface TaskDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: number | null;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
due_date: string;
|
||||
}
|
||||
|
||||
export const TASK_STATUSES: TaskStatus[] = ['open', 'in_progress', 'blocked'];
|
||||
export const TASK_PRIORITIES: TaskPriority[] = ['low', 'medium', 'high'];
|
||||
|
||||
export const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
};
|
||||
|
||||
export const TITLE_MAX_LENGTH = 120;
|
||||
export const DESCRIPTION_MAX_LENGTH = 1000;
|
||||
|
||||
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "es2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||