diff --git a/frontend/README.md b/frontend/README.md index 58beeac..782ff32 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/README.old.md b/frontend/README.old.md deleted file mode 100644 index bab8801..0000000 --- a/frontend/README.old.md +++ /dev/null @@ -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.* \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd54424..5c13a72 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index c54118f..807c17f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/preview/taskflow-blocked-desktop.png b/frontend/preview/taskflow-blocked-desktop.png new file mode 100644 index 0000000..d260396 Binary files /dev/null and b/frontend/preview/taskflow-blocked-desktop.png differ diff --git a/frontend/preview/taskflow-create-task-desktop.png b/frontend/preview/taskflow-create-task-desktop.png new file mode 100644 index 0000000..1052e94 Binary files /dev/null and b/frontend/preview/taskflow-create-task-desktop.png differ diff --git a/frontend/preview/taskflow-create-task-mobile.png b/frontend/preview/taskflow-create-task-mobile.png new file mode 100644 index 0000000..4a99ac4 Binary files /dev/null and b/frontend/preview/taskflow-create-task-mobile.png differ diff --git a/frontend/preview/taskflow-dashboard-desktop.png b/frontend/preview/taskflow-dashboard-desktop.png new file mode 100644 index 0000000..789e175 Binary files /dev/null and b/frontend/preview/taskflow-dashboard-desktop.png differ diff --git a/frontend/preview/taskflow-dashboard-mobile.png b/frontend/preview/taskflow-dashboard-mobile.png new file mode 100644 index 0000000..ab94af5 Binary files /dev/null and b/frontend/preview/taskflow-dashboard-mobile.png differ diff --git a/frontend/preview/taskflow-initial.png b/frontend/preview/taskflow-initial.png deleted file mode 100644 index 74a965f..0000000 Binary files a/frontend/preview/taskflow-initial.png and /dev/null differ diff --git a/frontend/preview/taskflow-validation-desktop.png b/frontend/preview/taskflow-validation-desktop.png new file mode 100644 index 0000000..f4b86d8 Binary files /dev/null and b/frontend/preview/taskflow-validation-desktop.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css index 0f56c24..289cd5d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; + } +} diff --git a/frontend/src/App.js b/frontend/src/App.js deleted file mode 100644 index 3784575..0000000 --- a/frontend/src/App.js +++ /dev/null @@ -1,25 +0,0 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/frontend/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..5a4436d --- /dev/null +++ b/frontend/src/App.test.tsx @@ -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; + +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 => { + render(); + 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(); + + 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(); + + 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(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41638b3..858a971 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,72 +1,202 @@ -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([]); + const [users, setUsers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [editingTask, setEditingTask] = useState(null); + const [pendingTaskId, setPendingTaskId] = useState(null); + const [notice, setNotice] = useState(null); - return ( -
-
-

TaskFlow

-
- Sarah Chen - -
-
- -
-
-

Team Members

-
-
Sarah Chen
-
Mike Johnson
-
Alex Kim
-
Emma Davis
-
-
- -
-
- - - -
- -
- {mockTasks.map(task => ( -
-
{task.title}
-
{task.assignee}
-
{task.status}
-
{task.priority}
-
- ))} -
-
-
-
- ); + /** Element that opened the dialog, so focus can go back where the user left it. */ + const formTriggerRef = useRef(null); + + const load = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + // 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 ( +
+ + ); + } + + if (loadError || !currentUser) { + return ( +
+

We could not load your tasks

+

{loadError ?? 'Your account details are unavailable right now.'}

+ +
+ ); + } + + return ( + +
+ {notice && ( +
+ {notice.text} + +
+ )} +
+ + openForm(null)} + onEditTask={openForm} + onDeleteTask={deleteTask} + onStatusChange={changeStatus} + /> + + {formOpen && ( + + )} +
+ ); } export default App; diff --git a/frontend/src/App_temp.css b/frontend/src/App_temp.css deleted file mode 100644 index 6d8c7da..0000000 --- a/frontend/src/App_temp.css +++ /dev/null @@ -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 { diff --git a/frontend/src/components/TaskForm/TaskForm.css b/frontend/src/components/TaskForm/TaskForm.css index 16365fd..ea7bb06 100644 --- a/frontend/src/components/TaskForm/TaskForm.css +++ b/frontend/src/components/TaskForm/TaskForm.css @@ -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; } } diff --git a/frontend/src/components/TaskForm/TaskForm.tsx b/frontend/src/components/TaskForm/TaskForm.tsx index e69de29..2bc9b2d 100644 --- a/frontend/src/components/TaskForm/TaskForm.tsx +++ b/frontend/src/components/TaskForm/TaskForm.tsx @@ -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; +} + +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 = ({ users, task, onCancel, onSubmit }) => { + const dialogRef = useRef(null); + const [submitError, setSubmitError] = useState(null); + + const { + register, + handleSubmit, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ + 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 => { + 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): void => { + if (event.key === 'Escape') { + event.stopPropagation(); + onCancel(); + return; + } + + if (event.key !== 'Tab' || !dialogRef.current) { + return; + } + + const focusable = Array.from(dialogRef.current.querySelectorAll(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 ( +
+
+
+
+

{task ? 'Edit Task' : 'Create New Task'}

+ +
+ + {submitError && ( +

+ {submitError} +

+ )} + +
+ + + {errors.title ? ( + + ) : ( +

+ {' '} + Required field +

+ )} +
+ +
+ +